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.url | The 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 |
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:
| Member | Standard | esrun / esdev | Node | Deno | Bun |
|---|---|---|---|---|---|
url | Yes | Yes | Yes | Yes | Yes |
resolve(specifier) | Yes | Yes | Yes | Yes | Yes |
resolve(specifier, parent) | No | No — esdev has resolve(specifier, from) in runtime:build | Behind --experimental-import-meta-resolve | No | No |
dirname | No | No | Yes (20.11, 21.2) | Yes, local modules | Yes |
filename | No | No | Yes (20.11, 21.2) | Yes, local modules | Yes |
main | No | No | Experimental (22.18, 24.2) | Yes | Yes |
dir, file, path | No | No | No | No | Yes |
env | No | No | No | No | Yes — 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:
| Member | Vite | esdev |
|---|---|---|
import.meta.env | VITE_* variables, MODE, BASE_URL, DEV, PROD, SSR | PUBLIC_* variables, MODE, DEV, PROD — in esdev build output only |
import.meta.hot | The HMR API | A subset of Vite's, with signal and keep added — in esdev start bundles only |
import.meta.glob(pattern) | Imports every file a pattern matches | Not 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.
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:
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:
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:
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.envis replaced in whatesdev buildwrites. In a module run unbundled, withesdev <file>oresdev test, it isundefined.import.meta.hotexists in the bundlesesdev startserves hot, and isundefinedeverywhere 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.
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:
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:
// 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:
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:
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:
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.