import.meta across runtimes

import.meta is an object every ES module gets, describing that module. ECMAScript defines the object and leaves its contents to the host. The web defines two members. Every server runtime adds its own, and build tools add more at compile time, so code that reaches past the two standard members is code that runs in one place.

This page lists what exists where, what esrun and esdev provide, and how to write module code that runs unchanged across them.

The standard: url and resolve

The HTML standard defines exactly two members, and every runtime below implements both:

Member
import.meta.urlThe module's absolute URL, as a string: "file:///app/server.js"
import.meta.resolve(specifier)The URL specifier names when imported from this module, as a string. Synchronous; one argument
JavaScript
const config = new URL("./config.json", import.meta.url);   // a file beside this module
const worker = import.meta.resolve("./worker.js");           // "file:///app/worker.js"

Anything else on import.meta is an extension.

What exists where

At run time

What each runtime puts on import.meta when a module runs:

MemberStandardesrun / esdevNodeDenoBun
urlYesYesYesYesYes
resolve(specifier)YesYesYesYesYes
resolve(specifier, parent)NoNo — esdev has resolve(specifier, from) in runtime:buildBehind --experimental-import-meta-resolveNoNo
dirnameNoNoYes (20.11, 21.2)Yes, local modulesYes
filenameNoNoYes (20.11, 21.2)Yes, local modulesYes
mainNoNoExperimental (22.18, 24.2)YesYes
dir, file, pathNoNoNoNoYes
envNoNoNoNoYes — an alias for process.env

At build time

Members a build tool compiles away: they exist in the source it reads and are replaced in the output it writes, so the runtime never sees them. They come from Vite, and esdev provides two of them in its own form:

MemberViteesdev
import.meta.envVITE_* variables, MODE, BASE_URL, DEV, PROD, SSRPUBLIC_* variables, MODE, DEV, PROD — in esdev build output only
import.meta.hotThe HMR APIA subset of Vite's, with signal and keep added — in esdev start bundles only
import.meta.glob(pattern)Imports every file a pattern matchesNot provided

Bun's import.meta.env and Vite's share a name and nothing else: Bun's is the live process environment at run time, Vite's and esdev's are values written into the bundle when it is built.

Versions

Node added dirname and filename in 20.11 and 21.2, and marked them stable in 22.16 and 24.0. main is experimental, from 22.18 and 24.2. import.meta.resolve is a release candidate since 20.6, and the parent argument is outside that: it needs the flag, and Node calls it non-standard.

What esrun and esdev provide

At run time, in esrun and esdev alike: url and resolve, nothing else.

import.meta.url is the module's URL, query included: ./config.js?v=2 is a module of its own, evaluated afresh, which is how a dev server re-reads a file that changed (Module system).

import.meta.resolve is the standard one-argument form:

JavaScript
import.meta.resolve("./schema.json");     // "file:///app/schema.json"
import.meta.resolve("runtime:process");   // "runtime:process"
import.meta.resolve("my-orm/migrations/001.sql");
// "file:///app/node_modules/my-orm/migrations/001.sql"

A relative path or a URL is pure URL resolution against the current module: no I/O, and no check that the target exists — resolving a path and importing it are separate questions.

A bare or #private specifier goes through the module loader, which is how a program locates a file shipped inside a dependency, wherever it was installed:

JavaScript
import { file } from "runtime:fs";

const sql = await file(new URL(import.meta.resolve("my-orm/migrations/001.sql"))).text();

That reads package.json files (Resolving packages), so it needs --allow-imports, as an import does, and obeys the same root jail and import policy: a run that may not import a package may not locate it either. A URL it returns is always one import() accepts.

A tool that has to resolve from somewhere other than its own module — a dev server finding the project's copy of a package rather than one nested under the tool — does it under esdev, with resolve(specifier, from) from runtime:build. The production binary serves neither that module nor the argument:

JavaScript
import { resolve } from "runtime:build";              // esdev only

const root = new URL("./", `file://${cwd()}/`);    // cwd from runtime:process
resolve("@opentf/web", root);                       // the project's copy, not the tool's

At build time, in esdev only:

  • import.meta.env is replaced in what esdev build writes. In a module run unbundled, with esdev <file> or esdev test, it is undefined.

  • import.meta.hot exists in the bundles esdev start serves hot, and is undefined everywhere else.

dirname, filename, main and Bun's dir, file, path and env are not provided: code that reads them gets undefined.

Writing code that runs everywhere

Use the two standard members, and derive the rest from them.

A file beside the module

A file: URL is the portable form: runtime:fs takes one wherever it takes a path, and so do Node's and Deno's file APIs.

JavaScript
const schema = new URL("./schema.sql", import.meta.url);

The module's directory or path

Derive them from import.meta.url rather than reading dirname or filename:

JavaScript
import { dirname, fromFileURL } from "runtime:path";

const filename = fromFileURL(import.meta.url);
const directory = dirname(filename);

runtime:path is this runtime's module. In a library meant for several runtimes, keep the value a URL (new URL(".", import.meta.url) is the directory) and convert it at the edge where a path is needed. See Migrating from Node for the rest of the __dirname translation.

Whether this module is the entry

import.meta.main has no portable form. Put the program in a module of its own and keep what it uses in modules that export it:

JavaScript
// lib.js — importable anywhere, runs nothing
export function run() { /* … */ }

// main.js — the entry, and only the entry
import { run } from "./lib.js";
run();

Resolving from somewhere else

The standard resolve resolves from the calling module only. Node has a second argument behind a flag; Deno, Bun and esrun do not. Portable code resolves from a module that lives where resolution should start. Code that only ever runs under esdev, such as a dev server or a build step, can use resolve(specifier, from) from runtime:build.

Environment values

import.meta.env is a build-time replacement, Vite's convention and esdev's, not an object that exists at run time (Bun's is the one exception). Read the environment where the program runs through runtime:process, which a deployment grants with --allow-env. In source that is also bundled, guard the build-time form:

JavaScript
const mode = import.meta.env?.MODE ?? "development";

Hot replacement

import.meta.hot is Vite's HMR convention, which esdev follows. Always put it behind a check, so the module still runs where there is no dev loop:

JavaScript
if (import.meta.hot) {
  import.meta.hot.accept();
}

Re-reading a module that changed

Import it under a new query. It works here, in browsers, whose module map is keyed by URL, and in Node, which documents it. Deno and Bun do not document how a query affects their module cache, so check before relying on it there:

JavaScript
const fresh = await import(`./config.js?v=${Date.now()}`);

Every version stays in memory for the life of the process, so this belongs in development tools, not in a loop that runs in production.

TypeScript

@opentf/esrun-types declares what this runtime provides: the one-argument resolve, and runtime:build's resolve(specifier, from) for esdev. import.meta.hot is declared for esdev. A library that reads import.meta.dirname compiles against Node's types and still gets undefined here, so the compiler cannot catch it. Stay on url and resolve.

References

Last updated on
Edit this page