How Runtime Logger works?
Astro Runtime Logger provides logger objects to the runtime in different ways for different purposes. On a server while rendering on-demand routes it provides a stub implementation of the AstroIntegrationLogger
defined by Astro. During dev and during prerendering it provides the actual implementation from Astro, but scoped to either the integration or the file.
Astro’s logger includes a .fork()
method that allows one logger to be created from another. This means that for the on-demand pages on the server we only need to get one logger there and then we can construct any other by forking it.
During prerendering and dev we can’t do that because we provide an extra guarantee for integrations: the logger object they receive will be the same object that is passed to the integration hooks. Not only that, we want the same object from the integration because the official implementation from Astro provides extra features like coloring since it can assume that the code is running on Node and on a terminal.
Additionally, we provice the extra convenience that projects using this package as an integration will receive a different logger for each file that imports it so they know where their logs are coming from.
Overall, there are a few challenges that need to be solved to create this package:
- Getting a logger implementation to the runtime for on-demand routes
- Transfering logger instances from integration to rendering modules during prerendering and dev
- Providing an API for integrations to use that expose their own logger for their modules
- Making an exported constant be different depending on which file is importing it
We’ll go through each one of them in turn and the reasoning behind them.
Structure overview
To achieve this, this package has:
- An internal integration that is not exposed and defines an API using Modular Station
- A public integration that is exposed for projects
- An AIK Utility for integrations to use loggers on their own modules
- A Vite plugin for the project integration
- 2 Vite plugins for the internal integration during build
- A Vite plugin for the internal integration during development
The structure of this package is as follows:
Runtime logger for on-demand routes
To get a logger at runtime we can either transfer the logger instance from build time to render runtime or construct something new at runtime. We use both, transfering when possible and constructing new instances otherwise.
For the final build output, the logger has to be entirely independent since there is no build scope to transfer from. To achieve this this package includes a copy the implementation of Astro’s own logger and adds it to the bundle. The implementation is slightly modified so it doesn’t rely on things that are not supposed to be used on the deployed server, like terminal control characters and Node-only modules. Because Astro’s implementation is supposed to be used only during build, it can assume those and be fancier (colors and things), but we don’t want to limit your project deployment options.
This runtime implementation is provided by the internal logger plugin that loads the Logger stub implementation as a virtual module:
The stub implementation is entirely unexported. Only a single instance is exported at the very end:
This baseLogger
constant is never used directly, that is why it has an empty string as its name. It will be forked for each logger instance needed at runtime. Notably, it has the same default level as Astro (warn
), but that is replaced with the value from build time later on.
Transfering logger instances during build and dev
While running astro build
or astro dev
, Astro already provide it’s own implementation of the loggers. That implementation has more features due to the extra assumptions it can make about the environment it is running on. In those cases we transfer the implementation from the scope of the integration to the rendering code.
To do that we use a little tricky that arises from how Astro does the prerendering. Astro generates the final server bundle containing the code to render all routes, not just the on-demand routes. It then imports that generated bundle in the same V8 isolate that is just generated the bundle, where all the build and integration logic is running, and calls the rendering code with a fake request representing each page that should be prerendered. The output HTML is saved and then served in front of the renderer code so it is not re-rendered at runtime.
What that means to us is that the global scope (the value of globalThis
) for integrations and the render code is the same during dev and prerendering. We can use that value to send references in both directions.
Dev server
To perform this transfer when running astro dev
, we inject a Vite plugin that does the following:
-
Stores a mapping of loggers by import names in the global scope;
The map is stored in the global scope under a global symbol (
Symbol.for
) created using the plugin name. This avoids collision with other libraries using the global scope for their own reasons. -
Resolves a virtual module ID for each import of a virtual module with a naming matching a logger import name;
-
Loads a source code for a resolved virtual module if the resolved module ID matches a logger import name;
-
In the source code for the virtual module, reads the appropriate logger from the map stored in the global scope.
Build prerendering
When running astro build
we can use the same idea, but there is a catch: the code used to prerender is the exact same code used for on-demand rendering, it is bundled only once. For that reason, we cannot use the same plugin as for astro dev
that just does the transfer assuming the global scope will be populated. Instead, we need to:
- Look for the mapping in the global scope to transfer;
- If it is there, use it directly;
- Otherwise, construct a new one by forking the
baseLogger
exported from the stub implementation explained above and configuring it with the same options as the logger had during build.
API for Integrations
Now that we have a way to both reconstruct and transfer loggers into runtime, we need a way to populate that loggers
mapping that is stored in the global scope.
To do this we wrap those plugins and the conditions for when to use which in an internal integration. This integration is not exported for consumers of the package. This integration exposes an inter-integration APIs using Modular Station which is then wrapped on an AIK Utility that is exported. The inter-integration API can’t be used directly since the integration is not exported.
- Define an Astro Integration that exposes an API using
withApi
from Modular Station; - Instantiate the logger mapping that will be sent to the plugins;
- Construct the appropriate plugins based on which command is being executed;
- Define the API for registering loggers into the mapping;
-
Define an AIK Utility for the
astro:config:setup
hook that receives a name;This
name
parameter is what will be used to import the logger, as seen on the previous section. -
Load the API from the internal integration;
This automatically installs the internal integration on the first use.
-
Adds the logger of the calling integration (provided by Astro as a parameter to the
astro:config:setup
) under the given name;
With everything up to here, we have implemented the behavior available for integration authors described in the “For integrations” section of this package main page.
Automatic logger selection
With the above API in place, we can now implement the loggers for project files. These loggers don’t come from an integration one-to-one like the what we described before. But you could call .fork()
on a logger from an integration to get a custom logger. That would be fine, but we provide an additional feature for those using Runtime Logger directly in their projects. You can import a logger from one single place (@it-astro:logger
) and it will automatically be forked with the name of the file you are importing from.
This feature is implemented using yet another Vite plugin that:
-
Receive a root path so we don’t include big absolute paths to files;
-
Resolve the
@it-astro:logger
module ID to a different value depending on which module is importing it;This
importer
value is provided by Vite on theresolveId
and only there. So we need to encode that in the resolved ID:- Compute the relative path between the given root and the importer;
- Add the relative path to an
URLSearchParams
object; - Include the string representation of the
URLSearchParams
object in the resolved ID.
-
Load the module source code for resolved IDs generated by the plugin;
This includes decoding
importer
from the search params added onresolveId
. -
Import the integration logger named
__project
that should be provided by the internal integration define before; -
Fork that logger with the name of the importer module.
The last piece needed is the public integration that users can use on their projects directly to have this functionality. It is also the integration that will be wired in the Astro config by the astro add
command.
- It is exported as the default export from the index file of the integration to match what is expected by
astro add
; - Uses the utility defined before to register its own logger under the
__project
name; - Adds a the project logger plugin we just created to resolve the loggers per file using the project
srcDir
as a base.
That’s it! We have implemented the behavior described in the “For projects” section of this package main page.
This is the entire implementation of the Runtime Logger package. The source code is available on GitHub under the MIT license.