Files
Bartek Iwańczuk fd41ccca84 feat(compile): persist Web Storage/KV in a per-app data directory (#34618)
In a `deno compile`-d binary, origin-bound storage did not work. The
default `Deno.openKv()` (called without an explicit path) silently fell
back to an in-memory database, so data never survived across runs, and
both `localStorage` and the Web `caches` API threw `NotSupported`.

The root cause was in the standalone runtime (`cli/rt/run.rs`), which
built the worker factory with `StorageKeyResolver::empty()` and
`origin_data_folder_path: None`. With no storage key the worker resolves
both `origin_storage_dir` and `cache_storage_dir` to `None`; KV then
opens
an in-memory connection and the storage globals are disabled.

Rather than route this through `DENO_DIR` the way `deno run` does, this
treats a compiled binary as a standalone application. Persistent storage
now lives under the platform's app data directory (`%LOCALAPPDATA%`,
`~/Library/Application Support`, `$XDG_DATA_HOME`), reusing the same
`get_data_local_dir()` resolution already used for self-extracting
binaries, keyed by an app name. This keeps a shipped binary from writing
user data into a Deno tooling cache that may not even exist on the
target
machine.

The app name is the single source of storage identity. It is baked in at
compile time from a new `--app-name` flag, falling back to the output
file
name when the flag is omitted, so it is stable across runs and even if
the
produced binary is later renamed. Recompiling with a different
`--output`/`--app-name` starts a fresh store. Because the name (not the
main-module URL) drives the directory, two binaries compiled with the
same
`--app-name` share a store while differently named apps stay isolated,
and
the previous footgun where two binaries that merely shared an executable
file name would collide is gone. A single fixed storage key is used per
app, so each app gets one store rather than per-origin namespacing;
`--location` is no longer involved in storage. When the data directory
cannot be resolved the storage key is left empty and the previous
in-memory fallback is preserved, which also avoids unwrapping a missing
data folder path in the worker.

The app name is carried in the compile `Metadata` so the standalone
runtime can read it back; `--app-version` (#29038) is a natural next
member of this same app-identity metadata.

Spec tests under `tests/specs/compile` compile a script and exercise it
across cold processes: `kv_default_storage_path` proves a default
`Deno.openKv()` survives between runs, `local_storage_default_path` does
the same for `localStorage`, `web_cache_storage` proves `caches` is
available, and `app_name_storage` proves two binaries sharing an
`--app-name` share a store while a differently named app does not. Each
test redirects the platform app data directory into its temp dir to stay
hermetic.

Fixes #24318
2026-06-23 21:30:11 +02:00

1269 lines
39 KiB
Rust

// Copyright 2018-2026 the Deno authors. MIT license.
use std::borrow::Cow;
use std::collections::HashMap;
use std::ffi::OsString;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::OnceLock;
use deno_core::FastString;
use deno_core::ModuleCodeBytes;
use deno_core::ModuleSourceCode;
use deno_core::ModuleType;
use deno_core::anyhow::Context;
use deno_core::anyhow::bail;
use deno_core::error::AnyError;
use deno_core::serde_json;
use deno_core::url::Url;
use deno_error::JsError;
use deno_error::JsErrorBox;
use deno_lib::standalone::binary::DenoRtDeserializable;
use deno_lib::standalone::binary::MAGIC_BYTES;
use deno_lib::standalone::binary::Metadata;
use deno_lib::standalone::binary::RemoteModuleEntry;
use deno_lib::standalone::binary::SpecifierDataStore;
use deno_lib::standalone::binary::SpecifierId;
use deno_lib::standalone::virtual_fs::VfsEntry;
use deno_lib::standalone::virtual_fs::VirtualDirectory;
use deno_lib::standalone::virtual_fs::VirtualDirectoryEntries;
use deno_media_type::MediaType;
use deno_npm::NpmPackageId;
use deno_npm::resolution::SerializedNpmResolutionSnapshot;
use deno_npm::resolution::SerializedNpmResolutionSnapshotPackage;
use deno_npm::resolution::ValidSerializedNpmResolutionSnapshot;
use deno_semver::StackString;
use deno_semver::package::PackageReq;
use indexmap::IndexMap;
use sys_traits::FsCanonicalize;
use sys_traits::FsRead;
use thiserror::Error;
use crate::file_system::FileBackedVfs;
use crate::file_system::VfsRoot;
pub struct StandaloneData {
pub metadata: Metadata,
pub modules: Arc<StandaloneModules>,
pub npm_snapshot: Option<ValidSerializedNpmResolutionSnapshot>,
pub root_path: PathBuf,
pub vfs: Arc<FileBackedVfs>,
}
/// This function will try to run this binary as a standalone binary
/// produced by `deno compile`. It determines if this is a standalone
/// binary by skipping over the trailer width at the end of the file,
/// then checking for the magic trailer string `d3n0l4nd`. If found,
/// the bundle is executed. If not, this function exits with `Ok(None)`.
pub fn extract_standalone(
cli_args: Cow<[OsString]>,
) -> Result<StandaloneData, AnyError> {
extract_standalone_with_finder(cli_args, find_section)
}
/// Like `extract_standalone`, but allows providing a custom section finder.
/// This is used by the desktop cdylib to search its own image rather than
/// the main executable.
pub fn extract_standalone_with_finder(
cli_args: Cow<[OsString]>,
section_finder: fn() -> Result<&'static [u8], AnyError>,
) -> Result<StandaloneData, AnyError> {
let data = section_finder()?;
// read metadata first to determine the root path
let (mut metadata, remaining) = read_section_metadata(data)?;
// for self-extracting executables, use the extraction directory as root
// so that module specifiers resolve to extracted file paths
let root_path = if let Some(hash) = &metadata.self_extracting {
let dir = choose_and_create_extraction_dir(hash)?;
sys_traits::impls::RealSys
.fs_canonicalize(&dir)
.unwrap_or(dir)
} else {
let maybe_current_exe = std::env::current_exe().ok();
let current_exe_name = maybe_current_exe
.as_ref()
.and_then(|p| p.file_name())
.map(|p| p.to_string_lossy())
// should never happen
.unwrap_or_else(|| Cow::Borrowed("binary"));
std::env::temp_dir().join(format!("deno-compile-{}", current_exe_name))
};
let root_url = deno_path_util::url_from_directory_path(&root_path)?;
let DeserializedDataSection {
npm_snapshot,
modules_store: remote_modules,
vfs_root_entries,
vfs_files_data,
} = deserialize_binary_data_section(&root_url, remaining)?;
let cli_args = cli_args.into_owned();
let current_exe = std::env::current_exe().ok();
let mut args_iter = cli_args.into_iter();
args_iter.next(); // skip argv[0]
// Node.js apps relaunch with spawn(process.execPath, [process.argv[1], ...args]).
// In standalone mode process.argv[1] === execPath (#32990), so the first arg
// after argv[0] is a duplicate of the exe path. Strip it.
//
// NOTE: this means `./myapp ./myapp --foo` would silently lose the first
// `./myapp` arg. In practice standalone binaries are never invoked this way,
// and this matches how Node.js SEA handles the relaunch pattern.
if let Some(first) = args_iter.next() {
let is_exe_dup = current_exe
.as_ref()
.is_some_and(|exe| Path::new(&first) == exe.as_path());
if !is_exe_dup {
metadata.argv.push(first.into_string().unwrap());
}
}
for arg in args_iter {
metadata.argv.push(arg.into_string().unwrap());
}
let vfs = {
let fs_root = VfsRoot {
dir: VirtualDirectory {
// align the name of the directory with the root dir
name: root_path
.file_name()
.unwrap()
.to_string_lossy()
.into_owned(),
entries: vfs_root_entries,
},
root_path: root_path.clone(),
start_file_offset: 0,
};
Arc::new(FileBackedVfs::new(
Cow::Borrowed(vfs_files_data),
fs_root,
metadata.vfs_case_sensitivity,
))
};
Ok(StandaloneData {
metadata,
modules: Arc::new(StandaloneModules {
modules: remote_modules,
vfs: vfs.clone(),
}),
npm_snapshot,
root_path,
vfs,
})
}
/// Extracts the embedded file system to disk for a self-extracting
/// executable. The extraction_dir is the directory where files will be
/// extracted, which should match the VFS root_path.
pub fn extract_vfs_to_disk(
vfs: &FileBackedVfs,
extraction_dir: &Path,
) -> Result<(), AnyError> {
// check if already extracted
let done_marker = extraction_dir.join(".done");
if done_marker.exists() {
log::debug!("Already extracted to {}", extraction_dir.display());
return Ok(());
}
log::debug!("Extracting to {}", extraction_dir.display());
let start = std::time::Instant::now();
std::fs::create_dir_all(extraction_dir).with_context(|| {
format!(
"Failed to create extraction directory: {}",
extraction_dir.display()
)
})?;
extract_vfs_dir(
vfs,
vfs.root_dir(),
&mut extraction_dir.to_path_buf(),
extraction_dir,
)
.context("Failed to extract embedded files to disk")?;
// write the done marker
std::fs::File::create(&done_marker)
.context("Failed to write extraction done marker")?;
log::debug!("Extracted in {}ms", start.elapsed().as_millis());
Ok(())
}
fn choose_and_create_extraction_dir(
hash_str: &str,
) -> Result<PathBuf, AnyError> {
let current_exe = std::env::current_exe()
.context("Failed to determine current executable path")?;
let exe_name = current_exe
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "binary".to_string());
// try next to the executable first
if let Some(exe_dir) = current_exe.parent() {
let dir = exe_dir.join(format!(".{}", exe_name)).join(hash_str);
match std::fs::create_dir_all(&dir) {
Ok(()) => return Ok(dir),
Err(err) => {
log::debug!(
"Could not create extraction dir next to executable ({}), falling back to data dir: {}",
err,
dir.display()
);
}
}
}
// fall back to platform-specific data directory
let data_dir = get_data_local_dir().context(
"Failed to determine local data directory for self-extracting executable",
)?;
let dir = data_dir.join(&exe_name).join(hash_str);
std::fs::create_dir_all(&dir).with_context(|| {
format!("Failed to create extraction directory: {}", dir.display())
})?;
Ok(dir)
}
pub(crate) fn get_data_local_dir() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
}
#[cfg(target_os = "macos")]
{
#[allow(clippy::disallowed_types, reason = "setup code")]
sys_traits::EnvHomeDir::env_home_dir(&sys_traits::impls::RealSys)
.map(|h| h.join("Library").join("Application Support"))
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| {
#[allow(clippy::disallowed_types, reason = "setup code")]
sys_traits::EnvHomeDir::env_home_dir(&sys_traits::impls::RealSys)
.map(|h| h.join(".local").join("share"))
})
}
}
fn extract_vfs_dir(
vfs: &FileBackedVfs,
dir: &VirtualDirectory,
disk_path: &mut PathBuf,
extraction_root: &Path,
) -> Result<(), AnyError> {
for entry in dir.entries.iter() {
match entry {
VfsEntry::Dir(sub_dir) => {
disk_path.push(&sub_dir.name);
// parent is guaranteed to exist since we recurse top-down
std::fs::create_dir(&*disk_path)
.or_else(|e| {
if e.kind() == std::io::ErrorKind::AlreadyExists {
Ok(())
} else {
Err(e)
}
})
.with_context(|| {
format!("Failed to create directory: {}", disk_path.display())
})?;
extract_vfs_dir(vfs, sub_dir, disk_path, extraction_root)?;
disk_path.pop();
}
VfsEntry::File(file) => {
disk_path.push(&file.name);
let data = vfs
.read_file_all(file)
.with_context(|| format!("Failed to read VFS file: {}", file.name))?;
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mode = if file.executable { 0o755 } else { 0o644 };
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(mode)
.open(&*disk_path)
.and_then(|mut f| f.write_all(&data))
.with_context(|| {
format!("Failed to write file: {}", disk_path.display())
})?;
}
#[cfg(not(unix))]
{
std::fs::write(&*disk_path, &*data).with_context(|| {
format!("Failed to write file: {}", disk_path.display())
})?;
}
disk_path.pop();
}
VfsEntry::Symlink(symlink) => {
disk_path.push(&symlink.name);
// symlink dest_parts are relative to the VFS root,
// so resolve them relative to the extraction root
let absolute_target = symlink.resolve_dest_from_root(extraction_root);
create_symlink(&absolute_target, disk_path, symlink.dest_is_dir)?;
disk_path.pop();
}
}
}
Ok(())
}
fn create_symlink(
target: &Path,
link_path: &Path,
dest_is_dir: bool,
) -> Result<(), AnyError> {
#[cfg(unix)]
{
_ = dest_is_dir; // unused on unix
std::os::unix::fs::symlink(target, link_path)
.or_else(|_| {
// may be left over from an interrupted extraction
let _ = std::fs::remove_file(link_path);
std::os::unix::fs::symlink(target, link_path)
})
.with_context(|| {
format!(
"Failed to create symlink: {} -> {}",
link_path.display(),
target.display()
)
})?;
}
#[cfg(windows)]
{
// on Windows, symlink_file and symlink_dir are distinct — using the
// wrong type causes PermissionDenied when accessing through the link
let create_link = |target: &Path, link: &Path| {
if dest_is_dir {
std::os::windows::fs::symlink_dir(target, link)
} else {
std::os::windows::fs::symlink_file(target, link)
}
};
create_link(target, link_path)
.or_else(|_| {
// may be left over from an interrupted extraction
let _ = std::fs::remove_file(link_path)
.or_else(|_| std::fs::remove_dir(link_path));
create_link(target, link_path)
})
.or_else(|_| {
// symlinks may require elevated privileges on Windows,
// fall back to junctions for dirs or copying for files
if dest_is_dir {
junction::create(target, link_path)
} else {
std::fs::copy(target, link_path).map(|_| ())
}
})
.with_context(|| {
format!(
"Failed to create symlink: {} -> {}",
link_path.display(),
target.display()
)
})?;
}
Ok(())
}
fn find_section() -> Result<&'static [u8], AnyError> {
#[cfg(windows)]
if std::env::var_os("DENO_INTERNAL_RT_USE_FILE_FALLBACK").is_some() {
return read_from_file_fallback();
}
match libsui::find_section("d3n0l4nd")
.context("Failed reading standalone binary section.")
{
Ok(Some(data)) => Ok(data),
Ok(None) => bail!("Could not find standalone binary section."),
Err(err) => {
#[cfg(windows)]
if let Ok(data) = read_from_file_fallback() {
return Ok(data);
}
Err(err)
}
}
}
/// This is a temporary hacky fallback until we can find
/// a fix for https://github.com/denoland/deno/issues/28982
#[cfg(windows)]
fn read_from_file_fallback() -> Result<&'static [u8], AnyError> {
use std::sync::OnceLock;
fn find_in_bytes(bytes: &[u8], needle: &[u8]) -> Option<usize> {
bytes.windows(needle.len()).position(|n| n == needle)
}
static FILE: OnceLock<std::fs::File> = OnceLock::new();
static MMAP_FILE: OnceLock<memmap2::Mmap> = OnceLock::new();
// DENOLAND in utf16
const RESOURCE_SECTION_HEADER_NAME: &[u8] = &[
0x44, 0x00, 0x33, 0x00, 0x4E, 0x00, 0x30, 0x00, 0x4C, 0x00, 0x34, 0x00,
0x4E, 0x00, 0x44, 0x00,
];
const MAGIC_BYTES: &[u8] = b"d3n0l4nd";
let file_path = std::env::current_exe()?;
let file = FILE.get_or_init(|| std::fs::File::open(file_path).unwrap());
let mmap = MMAP_FILE.get_or_init(|| {
// SAFETY: memory mapped file creation
unsafe { memmap2::Mmap::map(file).unwrap() }
});
// the code in this file will cause this to appear twice in the binary,
// so skip over the first one
let Some(marker_pos) = find_in_bytes(mmap, RESOURCE_SECTION_HEADER_NAME)
else {
bail!("Failed to find first section name.");
};
let next_bytes = &mmap[marker_pos + RESOURCE_SECTION_HEADER_NAME.len()..];
let Some(marker_pos) =
find_in_bytes(next_bytes, RESOURCE_SECTION_HEADER_NAME)
else {
bail!("Failed to find second section name.");
};
let next_bytes =
&next_bytes[marker_pos + RESOURCE_SECTION_HEADER_NAME.len()..];
let Some(ascii_pos) = find_in_bytes(next_bytes, MAGIC_BYTES) else {
bail!("Failed to find first magic bytes.");
};
let next_bytes = &next_bytes[ascii_pos..];
let Some(last_pos) = next_bytes
.windows(MAGIC_BYTES.len())
.rposition(|w| w == MAGIC_BYTES)
else {
bail!("Failed to find end magic bytes.")
};
Ok(&next_bytes[..last_pos + MAGIC_BYTES.len()])
}
pub struct DeserializedDataSection {
pub npm_snapshot: Option<ValidSerializedNpmResolutionSnapshot>,
pub modules_store: RemoteModulesStore,
pub vfs_root_entries: VirtualDirectoryEntries,
pub vfs_files_data: &'static [u8],
}
fn read_magic_bytes(input: &[u8]) -> Result<(&[u8], bool), AnyError> {
if input.len() < MAGIC_BYTES.len() {
bail!("Unexpected end of data. Could not find magic bytes.");
}
let (magic_bytes, input) = input.split_at(MAGIC_BYTES.len());
if magic_bytes != MAGIC_BYTES {
return Ok((input, false));
}
Ok((input, true))
}
/// Reads the magic bytes and metadata from the beginning of the data section.
/// Returns the metadata and the remaining input after the metadata.
fn read_section_metadata(
data: &'static [u8],
) -> Result<(Metadata, &'static [u8]), AnyError> {
let (input, found) = read_magic_bytes(data)?;
if !found {
bail!("Did not find magic bytes.");
}
let (input, metadata_bytes) =
read_bytes_with_u64_len(input).context("reading metadata")?;
let metadata: Metadata =
serde_json::from_slice(metadata_bytes).context("deserializing metadata")?;
Ok((metadata, input))
}
/// Deserializes the binary data section after the metadata has already been
/// parsed by `read_section_metadata`.
fn deserialize_binary_data_section(
root_dir_url: &Url,
input: &'static [u8],
) -> Result<DeserializedDataSection, AnyError> {
// 1. Was the metadata above.
// 2. Npm snapshot
let (input, data) =
read_bytes_with_u64_len(input).context("reading npm snapshot")?;
let npm_snapshot = if data.is_empty() {
None
} else {
Some(deserialize_npm_snapshot(data).context("deserializing npm snapshot")?)
};
// 3. Specifiers
let (input, specifiers_store) =
SpecifierStore::deserialize(root_dir_url, input)
.context("deserializing specifiers")?;
// 4. Redirects
let (input, redirects_store) =
SpecifierDataStore::<SpecifierId>::deserialize(input)
.context("deserializing redirects")?;
// 5. Remote modules
let (input, remote_modules_store) =
SpecifierDataStore::<RemoteModuleEntry<'static>>::deserialize(input)
.context("deserializing remote modules")?;
// 6. VFS
let (input, data) = read_bytes_with_u64_len(input).context("vfs")?;
let vfs_root_entries: VirtualDirectoryEntries =
serde_json::from_slice(data).context("deserializing vfs data")?;
let (input, vfs_files_data) =
read_bytes_with_u64_len(input).context("reading vfs files data")?;
// finally ensure we read the magic bytes at the end
let (_input, found) = read_magic_bytes(input)?;
if !found {
bail!("Could not find magic bytes at end of data.");
}
let modules_store = RemoteModulesStore::new(
specifiers_store,
redirects_store,
remote_modules_store,
);
Ok(DeserializedDataSection {
npm_snapshot,
modules_store,
vfs_root_entries,
vfs_files_data,
})
}
struct SpecifierStore {
data: IndexMap<Arc<Url>, SpecifierId>,
reverse: IndexMap<SpecifierId, Arc<Url>>,
}
impl SpecifierStore {
pub fn deserialize<'a>(
root_dir_url: &Url,
input: &'a [u8],
) -> std::io::Result<(&'a [u8], Self)> {
let (input, len) = read_u32_as_usize(input)?;
let mut data = IndexMap::with_capacity(len);
let mut reverse = IndexMap::with_capacity(len);
let mut input = input;
for _ in 0..len {
let (new_input, specifier_str) = read_string_lossy(input)?;
let specifier = match Url::parse(&specifier_str) {
Ok(url) => url,
Err(err) => match root_dir_url.join(&specifier_str) {
Ok(url) => url,
Err(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
err,
));
}
},
};
let (new_input, id) = SpecifierId::deserialize(new_input)?;
let specifier = Arc::new(specifier);
data.insert(specifier.clone(), id);
reverse.insert(id, specifier);
input = new_input;
}
Ok((input, Self { data, reverse }))
}
pub fn get_id(&self, specifier: &Url) -> Option<SpecifierId> {
self.data.get(specifier).cloned()
}
pub fn get_specifier(&self, specifier_id: SpecifierId) -> Option<&Url> {
self.reverse.get(&specifier_id).map(|url| url.as_ref())
}
}
pub struct StandaloneModules {
modules: RemoteModulesStore,
vfs: Arc<FileBackedVfs>,
}
impl StandaloneModules {
pub fn resolve_specifier<'a>(
&'a self,
specifier: &'a Url,
) -> Result<Option<&'a Url>, TooManyRedirectsError> {
if specifier.scheme() == "file" {
Ok(Some(specifier))
} else {
self.modules.resolve_specifier(specifier)
}
}
pub fn path_in_root(&self, path: &Path) -> bool {
deno_path_util::normalize_path(Cow::Borrowed(path))
.starts_with(self.vfs.root())
}
pub fn read<'a>(
&'a self,
specifier: &'a Url,
) -> Result<Option<DenoCompileModuleData<'a>>, JsErrorBox> {
if specifier.scheme() == "file" {
let path = deno_path_util::url_to_file_path(specifier)
.map_err(JsErrorBox::from_err)?;
let mut transpiled = None;
let mut source_map = None;
let mut cjs_export_analysis = None;
let mut is_valid_utf8 = false;
let bytes = match self.vfs.file_entry(&path) {
Ok(entry) => {
let bytes = self
.vfs
.read_file_all(entry)
.map_err(JsErrorBox::from_err)?;
is_valid_utf8 = entry.is_valid_utf8;
transpiled = entry
.transpiled_offset
.and_then(|t| self.vfs.read_file_offset_with_len(t).ok());
source_map = entry
.source_map_offset
.and_then(|t| self.vfs.read_file_offset_with_len(t).ok());
cjs_export_analysis = entry
.cjs_export_analysis_offset
.and_then(|t| self.vfs.read_file_offset_with_len(t).ok());
bytes
}
Err(err) if err.kind() == ErrorKind::NotFound => {
#[allow(
clippy::disallowed_types,
reason = "use real file system because not in vfs"
)]
let bytes = match sys_traits::impls::RealSys.fs_read(&path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == ErrorKind::NotFound => {
return Ok(None);
}
Err(err) => return Err(JsErrorBox::from_err(err)),
};
// A file read from disk at runtime (e.g. a plugin discovered by a
// compiled program) hasn't been transpiled at compile time, so
// transpile TypeScript/JSX here, mirroring `deno run`.
let media_type = MediaType::from_specifier(specifier);
if matches!(
media_type,
MediaType::TypeScript
| MediaType::Mts
| MediaType::Cts
| MediaType::Jsx
| MediaType::Tsx
) {
let text = String::from_utf8_lossy(&bytes).into_owned();
let transpiled_text =
transpile_runtime_module(specifier, media_type, text)?;
transpiled = Some(Cow::Owned(transpiled_text.into_bytes()));
}
bytes
}
Err(err) => return Err(JsErrorBox::from_err(err)),
};
Ok(Some(DenoCompileModuleData {
media_type: MediaType::from_specifier(specifier),
specifier,
is_valid_utf8,
data: bytes,
transpiled,
source_map,
cjs_export_analysis,
}))
} else {
self.modules.read(specifier).map_err(JsErrorBox::from_err)
}
}
}
/// Transpile TypeScript/JSX source to JavaScript for modules that are not
/// embedded in the binary, namely `data:`/`blob:` URLs and local files read
/// from disk at runtime. Embedded modules are already transpiled at
/// `deno compile` time, but a compiled program can still dynamically `import()`
/// TypeScript that it builds or discovers while running. `deno_ast` is already
/// linked into the binary via `ext/node`, so transpiling here adds no extra
/// binary size.
///
/// JSX configuration (`jsx`, `jsxImportSource`, `jsxFactory`, etc.) is
/// discovered by walking up from the source file to find the user's
/// `deno.json` / `deno.jsonc` once and caching the result. Without this,
/// every `.tsx` from a Preact-based project would be emitted as
/// `React.createElement(...)` and fail at runtime with `React is not defined`.
pub(crate) fn transpile_runtime_module(
specifier: &Url,
media_type: MediaType,
source: String,
) -> Result<String, JsErrorBox> {
match media_type {
MediaType::TypeScript
| MediaType::Mts
| MediaType::Cts
| MediaType::Jsx
| MediaType::Tsx => {}
// JavaScript, JSON, Wasm, etc. are served verbatim.
_ => return Ok(source),
}
let parsed = deno_ast::parse_module(deno_ast::ParseParams {
specifier: specifier.clone(),
text: source.into(),
media_type,
capture_tokens: false,
scope_analysis: false,
maybe_syntax: None,
})
.map_err(JsErrorBox::from_err)?;
let opts = runtime_transpile_options(specifier);
let transpiled = parsed
.transpile(
opts,
&deno_ast::TranspileModuleOptions { module_kind: None },
&deno_ast::EmitOptions {
source_map: deno_ast::SourceMapOption::Inline,
..Default::default()
},
)
.map_err(JsErrorBox::from_err)?;
Ok(transpiled.into_source().text)
}
/// Resolved JSX/decorator options for runtime transpiles, discovered lazily
/// from the user's project `deno.json[c]` and cached for the lifetime of the
/// process.
static RUNTIME_TRANSPILE_OPTIONS: OnceLock<deno_ast::TranspileOptions> =
OnceLock::new();
/// Walk up from `specifier`'s parent directory looking for `deno.json` or
/// `deno.jsonc`, parse `compilerOptions.jsx` + related, and return a matching
/// `TranspileOptions`. Falls back to defaults (with `ImportsNotUsedAsValues::Remove`)
/// if no config is found or parsing fails — those are non-fatal here.
fn runtime_transpile_options(
specifier: &Url,
) -> &'static deno_ast::TranspileOptions {
RUNTIME_TRANSPILE_OPTIONS.get_or_init(|| {
let fallback = deno_ast::TranspileOptions {
imports_not_used_as_values: deno_ast::ImportsNotUsedAsValues::Remove,
..Default::default()
};
let Ok(path) = deno_path_util::url_to_file_path(specifier) else {
return fallback;
};
let Some(mut dir) = path.parent().map(|p| p.to_path_buf()) else {
return fallback;
};
let cfg_path = 'outer: loop {
for name in ["deno.json", "deno.jsonc"] {
let candidate = dir.join(name);
if candidate.exists() {
break 'outer Some(candidate);
}
}
if !dir.pop() {
break 'outer None;
}
};
let Some(cfg_path) = cfg_path else {
return fallback;
};
let Ok(text) = std::fs::read_to_string(&cfg_path) else {
return fallback;
};
let Some(json) =
jsonc_parser::parse_to_serde_value(&text, &Default::default())
.ok()
.flatten()
else {
return fallback;
};
let json: &serde_json::Value = &json;
transpile_options_from_compiler_options(
json.get("compilerOptions"),
&fallback,
)
})
}
/// Lower the relevant subset of `compilerOptions` (the same one
/// `cli/tools/pack/mod.rs::create_transpile_options` handles) into
/// `TranspileOptions`. Anything missing falls back to `fallback`.
fn transpile_options_from_compiler_options(
compiler_options: Option<&serde_json::Value>,
fallback: &deno_ast::TranspileOptions,
) -> deno_ast::TranspileOptions {
let get_str = |key: &str| -> Option<String> {
compiler_options?.get(key)?.as_str().map(|s| s.to_string())
};
let get_bool = |key: &str| -> bool {
compiler_options
.and_then(|opts| opts.get(key))
.and_then(|v| v.as_bool())
.unwrap_or(false)
};
let jsx = get_str("jsx");
let jsx_import_source = get_str("jsxImportSource");
let jsx_factory = get_str("jsxFactory");
let jsx_fragment_factory = get_str("jsxFragmentFactory");
let jsx_runtime = match jsx.as_deref() {
Some("react") => {
Some(deno_ast::JsxRuntime::Classic(deno_ast::JsxClassicOptions {
factory: jsx_factory
.unwrap_or_else(|| "React.createElement".to_string()),
fragment_factory: jsx_fragment_factory
.unwrap_or_else(|| "React.Fragment".to_string()),
}))
}
Some("react-jsx") => Some(deno_ast::JsxRuntime::Automatic(
deno_ast::JsxAutomaticOptions {
development: false,
import_source: jsx_import_source,
},
)),
Some("react-jsxdev") => Some(deno_ast::JsxRuntime::Automatic(
deno_ast::JsxAutomaticOptions {
development: true,
import_source: jsx_import_source,
},
)),
Some("precompile") => Some(deno_ast::JsxRuntime::Precompile(
deno_ast::JsxPrecompileOptions {
automatic: deno_ast::JsxAutomaticOptions {
development: false,
import_source: jsx_import_source,
},
skip_elements: None,
dynamic_props: None,
},
)),
_ => fallback.jsx.clone(),
};
let experimental_decorators = get_bool("experimentalDecorators");
let emit_decorator_metadata = get_bool("emitDecoratorMetadata");
deno_ast::TranspileOptions {
jsx: jsx_runtime,
decorators: if experimental_decorators {
deno_ast::DecoratorsTranspileOption::LegacyTypeScript {
emit_metadata: emit_decorator_metadata,
}
} else {
deno_ast::DecoratorsTranspileOption::Ecma
},
imports_not_used_as_values: deno_ast::ImportsNotUsedAsValues::Remove,
var_decl_imports: false,
verbatim_module_syntax: false,
}
}
pub struct DenoCompileModuleData<'a> {
pub specifier: &'a Url,
pub media_type: MediaType,
pub is_valid_utf8: bool,
pub data: Cow<'static, [u8]>,
pub transpiled: Option<Cow<'static, [u8]>>,
pub source_map: Option<Cow<'static, [u8]>>,
pub cjs_export_analysis: Option<Cow<'static, [u8]>>,
}
impl<'a> DenoCompileModuleData<'a> {
pub fn into_parts(self) -> (&'a Url, ModuleType, DenoCompileModuleSource) {
fn into_string_unsafe(
is_valid_utf8: bool,
data: Cow<'static, [u8]>,
) -> DenoCompileModuleSource {
match data {
Cow::Borrowed(d) if is_valid_utf8 => {
DenoCompileModuleSource::String(
// SAFETY: we know this is a valid utf8 string
unsafe { std::str::from_utf8_unchecked(d) },
)
}
Cow::Borrowed(_) => DenoCompileModuleSource::Bytes(data),
Cow::Owned(d) => DenoCompileModuleSource::Bytes(Cow::Owned(d)),
}
}
let data = self.transpiled.unwrap_or(self.data);
let (media_type, source) = match self.media_type {
MediaType::JavaScript
| MediaType::Jsx
| MediaType::Mjs
| MediaType::Cjs
| MediaType::TypeScript
| MediaType::Mts
| MediaType::Cts
| MediaType::Dts
| MediaType::Dmts
| MediaType::Dcts
| MediaType::Tsx => (
ModuleType::JavaScript,
into_string_unsafe(self.is_valid_utf8, data),
),
MediaType::Json => (
ModuleType::Json,
into_string_unsafe(self.is_valid_utf8, data),
),
MediaType::Wasm => {
(ModuleType::Wasm, DenoCompileModuleSource::Bytes(data))
}
// just assume javascript if we made it here
MediaType::Css
| MediaType::Html
| MediaType::Jsonc
| MediaType::Json5
| MediaType::Markdown
| MediaType::SourceMap
| MediaType::Sql
| MediaType::Unknown => {
(ModuleType::JavaScript, DenoCompileModuleSource::Bytes(data))
}
};
(self.specifier, media_type, source)
}
}
#[derive(Debug)]
pub enum DenoCompileModuleSource {
String(&'static str),
Bytes(Cow<'static, [u8]>),
}
impl DenoCompileModuleSource {
pub fn into_for_v8(self) -> ModuleSourceCode {
match self {
// todo(https://github.com/denoland/deno_core/pull/943): store whether
// the string is ascii or not ahead of time so we can avoid the is_ascii()
// check in FastString::from_static
Self::String(s) => ModuleSourceCode::String(FastString::from_static(s)),
Self::Bytes(b) => ModuleSourceCode::Bytes(module_source_into_bytes(b)),
}
}
pub fn into_bytes_for_v8(self) -> ModuleCodeBytes {
match self {
DenoCompileModuleSource::String(text) => text.as_bytes().into(),
DenoCompileModuleSource::Bytes(b) => module_source_into_bytes(b),
}
}
}
fn module_source_into_bytes(data: Cow<'static, [u8]>) -> ModuleCodeBytes {
match data {
Cow::Borrowed(d) => d.into(),
Cow::Owned(d) => d.into_boxed_slice().into(),
}
}
#[derive(Debug, Error, JsError)]
#[class(generic)]
#[error("Too many redirects resolving: {0}")]
pub struct TooManyRedirectsError(Url);
pub struct RemoteModulesStore {
specifiers: SpecifierStore,
redirects: SpecifierDataStore<SpecifierId>,
remote_modules: SpecifierDataStore<RemoteModuleEntry<'static>>,
}
impl RemoteModulesStore {
fn new(
specifiers: SpecifierStore,
redirects: SpecifierDataStore<SpecifierId>,
remote_modules: SpecifierDataStore<RemoteModuleEntry<'static>>,
) -> Self {
Self {
specifiers,
redirects,
remote_modules,
}
}
pub fn resolve_specifier<'a>(
&'a self,
specifier: &'a Url,
) -> Result<Option<&'a Url>, TooManyRedirectsError> {
let Some(mut current) = self.specifiers.get_id(specifier) else {
return Ok(None);
};
let mut count = 0;
loop {
if count > 10 {
return Err(TooManyRedirectsError(specifier.clone()));
}
match self.redirects.get(current) {
Some(to) => {
current = *to;
count += 1;
}
None => {
if count == 0 {
return Ok(Some(specifier));
} else {
return Ok(self.specifiers.get_specifier(current));
}
}
}
}
}
pub fn read<'a>(
&'a self,
original_specifier: &'a Url,
) -> Result<Option<DenoCompileModuleData<'a>>, TooManyRedirectsError> {
#[allow(clippy::ptr_arg, reason = "Cow on data is relevant here")]
fn handle_cow_ref(data: &Cow<'static, [u8]>) -> Cow<'static, [u8]> {
match data {
Cow::Borrowed(data) => Cow::Borrowed(data),
Cow::Owned(data) => {
// this variant should never happen because the data
// should always be borrowed static in denort
debug_assert!(false);
Cow::Owned(data.clone())
}
}
}
let mut count = 0;
let Some(mut specifier) = self.specifiers.get_id(original_specifier) else {
return Ok(None);
};
loop {
if count > 10 {
return Err(TooManyRedirectsError(original_specifier.clone()));
}
match self.redirects.get(specifier) {
Some(to) => {
specifier = *to;
count += 1;
}
None => {
let Some(entry) = self.remote_modules.get(specifier) else {
return Ok(None);
};
return Ok(Some(DenoCompileModuleData {
specifier: if count == 0 {
original_specifier
} else {
self.specifiers.get_specifier(specifier).unwrap()
},
media_type: entry.media_type,
is_valid_utf8: entry.is_valid_utf8,
data: handle_cow_ref(&entry.data),
transpiled: entry.maybe_transpiled.as_ref().map(handle_cow_ref),
source_map: entry.maybe_source_map.as_ref().map(handle_cow_ref),
cjs_export_analysis: entry
.maybe_cjs_export_analysis
.as_ref()
.map(handle_cow_ref),
}));
}
}
}
}
}
fn deserialize_npm_snapshot(
input: &[u8],
) -> Result<ValidSerializedNpmResolutionSnapshot, AnyError> {
fn parse_id(input: &[u8]) -> Result<(&[u8], NpmPackageId), AnyError> {
let (input, id) = read_string_lossy(input)?;
let id = NpmPackageId::from_serialized(&id)?;
Ok((input, id))
}
#[allow(clippy::needless_lifetimes, reason = "clippy bug")]
#[allow(clippy::type_complexity, reason = "private code")]
fn parse_root_package<'a>(
id_to_npm_id: &'a impl Fn(usize) -> Result<NpmPackageId, AnyError>,
) -> impl Fn(&[u8]) -> Result<(&[u8], (PackageReq, NpmPackageId)), AnyError> + 'a
{
|input| {
let (input, req) = read_string_lossy(input)?;
let req = PackageReq::from_str(&req)?;
let (input, id) = read_u32_as_usize(input)?;
Ok((input, (req, id_to_npm_id(id)?)))
}
}
#[allow(clippy::needless_lifetimes, reason = "clippy bug")]
#[allow(clippy::type_complexity, reason = "private code")]
fn parse_package_dep<'a>(
id_to_npm_id: &'a impl Fn(usize) -> Result<NpmPackageId, AnyError>,
) -> impl Fn(&[u8]) -> Result<(&[u8], (StackString, NpmPackageId)), AnyError> + 'a
{
|input| {
let (input, req) = read_string_lossy(input)?;
let (input, id) = read_u32_as_usize(input)?;
let req = StackString::from_cow(req);
Ok((input, (req, id_to_npm_id(id)?)))
}
}
fn parse_package<'a>(
input: &'a [u8],
id: NpmPackageId,
id_to_npm_id: &impl Fn(usize) -> Result<NpmPackageId, AnyError>,
) -> Result<(&'a [u8], SerializedNpmResolutionSnapshotPackage), AnyError> {
let (input, deps_len) = read_u32_as_usize(input)?;
let (input, dependencies) =
parse_hashmap_n_times(input, deps_len, parse_package_dep(id_to_npm_id))?;
Ok((
input,
SerializedNpmResolutionSnapshotPackage {
id,
system: Default::default(),
dist: Default::default(),
dependencies,
optional_dependencies: Default::default(),
optional_peer_dependencies: Default::default(),
has_bin: false,
has_scripts: false,
is_deprecated: false,
extra: Default::default(),
},
))
}
let (input, packages_len) = read_u32_as_usize(input)?;
// get a hashmap of all the npm package ids to their serialized ids
let (input, data_ids_to_npm_ids) =
parse_vec_n_times(input, packages_len, parse_id)
.context("deserializing id")?;
let data_id_to_npm_id = |id: usize| {
data_ids_to_npm_ids
.get(id)
.cloned()
.ok_or_else(|| deno_core::anyhow::anyhow!("Invalid npm package id"))
};
let (input, root_packages_len) = read_u32_as_usize(input)?;
let (input, root_packages) = parse_hashmap_n_times(
input,
root_packages_len,
parse_root_package(&data_id_to_npm_id),
)
.context("deserializing root package")?;
let (input, packages) =
parse_vec_n_times_with_index(input, packages_len, |input, index| {
parse_package(input, data_id_to_npm_id(index)?, &data_id_to_npm_id)
})
.context("deserializing package")?;
if !input.is_empty() {
bail!("Unexpected data left over");
}
Ok(
SerializedNpmResolutionSnapshot {
packages,
root_packages,
}
// this is ok because we have already verified that all the
// identifiers found in the snapshot are valid via the
// npm package id -> npm package id mapping
.into_valid_unsafe(),
)
}
fn parse_hashmap_n_times<TKey: std::cmp::Eq + std::hash::Hash, TValue>(
mut input: &[u8],
times: usize,
parse: impl Fn(&[u8]) -> Result<(&[u8], (TKey, TValue)), AnyError>,
) -> Result<(&[u8], HashMap<TKey, TValue>), AnyError> {
let mut results = HashMap::with_capacity(times);
for _ in 0..times {
let result = parse(input);
let (new_input, (key, value)) = result?;
results.insert(key, value);
input = new_input;
}
Ok((input, results))
}
fn parse_vec_n_times<TResult>(
input: &[u8],
times: usize,
parse: impl Fn(&[u8]) -> Result<(&[u8], TResult), AnyError>,
) -> Result<(&[u8], Vec<TResult>), AnyError> {
parse_vec_n_times_with_index(input, times, |input, _index| parse(input))
}
fn parse_vec_n_times_with_index<TResult>(
mut input: &[u8],
times: usize,
parse: impl Fn(&[u8], usize) -> Result<(&[u8], TResult), AnyError>,
) -> Result<(&[u8], Vec<TResult>), AnyError> {
let mut results = Vec::with_capacity(times);
for i in 0..times {
let result = parse(input, i);
let (new_input, result) = result?;
results.push(result);
input = new_input;
}
Ok((input, results))
}
fn read_bytes_with_u64_len(input: &[u8]) -> std::io::Result<(&[u8], &[u8])> {
let (input, len) = read_u64(input)?;
let (input, data) = read_bytes(input, len as usize)?;
Ok((input, data))
}
fn read_bytes_with_u32_len(input: &[u8]) -> std::io::Result<(&[u8], &[u8])> {
let (input, len) = read_u32_as_usize(input)?;
let (input, data) = read_bytes(input, len)?;
Ok((input, data))
}
fn read_bytes(input: &[u8], len: usize) -> std::io::Result<(&[u8], &[u8])> {
check_has_len(input, len)?;
let (len_bytes, input) = input.split_at(len);
Ok((input, len_bytes))
}
#[inline(always)]
fn check_has_len(input: &[u8], len: usize) -> std::io::Result<()> {
if input.len() < len {
Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Unexpected end of data",
))
} else {
Ok(())
}
}
fn read_string_lossy(input: &[u8]) -> std::io::Result<(&[u8], Cow<'_, str>)> {
let (input, data_bytes) = read_bytes_with_u32_len(input)?;
Ok((input, String::from_utf8_lossy(data_bytes)))
}
fn read_u32_as_usize(input: &[u8]) -> std::io::Result<(&[u8], usize)> {
let (input, len_bytes) = read_bytes(input, 4)?;
let len = u32::from_le_bytes(len_bytes.try_into().unwrap());
Ok((input, len as usize))
}
fn read_u64(input: &[u8]) -> std::io::Result<(&[u8], u64)> {
let (input, len_bytes) = read_bytes(input, 8)?;
let len = u64::from_le_bytes(len_bytes.try_into().unwrap());
Ok((input, len))
}