Read OSS

Inside the Worker: Bootstrap, the Deno Namespace, and the Permissions System

Advanced

Prerequisites

  • Articles 1-3
  • Understanding of V8 isolates and contexts

Inside the Worker: Bootstrap, the Deno Namespace, and the Permissions System

We've covered the CLI dispatch (Article 1), the extension system (Article 2), and module loading (Article 3). Now we arrive at the heart of Deno's runtime: the MainWorker. This is the struct that owns the V8 isolate, drives the event loop, and provides the Deno.* API surface that user code interacts with. In this article we'll trace its creation through heavily generic WorkerOptions, watch the bootstrap sequence cross the Rust-JavaScript boundary, see how 90_deno_ns.js assembles the Deno global from dozens of extension modules, and examine the 8-type permissions system that makes Deno's "secure by default" promise real.

MainWorker Creation and WorkerOptions

The MainWorker struct is surprisingly compact — it wraps a JsRuntime with a handful of V8 function handles for lifecycle events:

pub struct MainWorker {
  pub js_runtime: JsRuntime,
  should_break_on_first_statement: bool,
  should_wait_for_inspector_session: bool,
  exit_code: ExitCode,
  bootstrap_fn_global: Option<v8::Global<v8::Function>>,
  dispatch_load_event_fn_global: v8::Global<v8::Function>,
  dispatch_beforeunload_event_fn_global: v8::Global<v8::Function>,
  dispatch_unload_event_fn_global: v8::Global<v8::Function>,
  dispatch_process_beforeexit_event_fn_global: v8::Global<v8::Function>,
  dispatch_process_exit_event_fn_global: v8::Global<v8::Function>,
}

Those v8::Global<v8::Function> handles are pointers to JavaScript functions extracted during initialization and stored for later invocation. The bootstrap function is consumed (taken via Option::take) during the bootstrap phase; the event dispatch functions persist for the worker's lifetime.

The creation is parameterized by WorkerServiceOptions, which carries three generic type parameters:

pub struct WorkerServiceOptions<
  TInNpmPackageChecker: InNpmPackageChecker,
  TNpmPackageFolderResolver: NpmPackageFolderResolver,
  TExtNodeSys: ExtNodeSys,
> {
  pub module_loader: Rc<dyn ModuleLoader>,
  pub permissions: PermissionsContainer,
  pub fs: Arc<dyn FileSystem>,
  pub blob_store: Arc<BlobStore>,
  pub node_services: Option<NodeExtInitServices<...>>,
  pub v8_code_cache: Option<Arc<dyn CodeCache>>,
  // ...
}

These generics enable different configurations: the CLI uses DenoInNpmPackageChecker and NpmResolver<RealSys>, the standalone binary uses its own implementations backed by the virtual filesystem, and tests can mock everything.

classDiagram
    class MainWorker {
        +js_runtime: JsRuntime
        +bootstrap(options)
        +execute_main_module(url)
        +run_event_loop()
        +dispatch_load_event()
    }
    class WorkerServiceOptions~T1,T2,T3~ {
        +module_loader: Rc~dyn ModuleLoader~
        +permissions: PermissionsContainer
        +fs: Arc~dyn FileSystem~
        +node_services: Option~NodeExtInitServices~
    }
    class WorkerOptions {
        +bootstrap: BootstrapOptions
        +extensions: Vec~Extension~
        +startup_snapshot: Option~&[u8]~
        +unconfigured_runtime: Option~UnconfiguredRuntime~
    }
    MainWorker <.. WorkerServiceOptions : created from
    MainWorker <.. WorkerOptions : created from

The Bootstrap Sequence: Rust → V8 → JavaScript

The bootstrap() method is where Rust calls into JavaScript for the first time:

pub fn bootstrap(&mut self, options: BootstrapOptions) {
  {
    let op_state = self.js_runtime.op_state();
    let mut state = op_state.borrow_mut();
    state.put(options.clone());
    if let Some((fd, serialization)) = options.node_ipc_init {
      state.put(deno_node::ChildPipeFd(fd, serialization));
    }
  }

  deno_core::scope!(scope, &mut self.js_runtime);
  v8::tc_scope!(scope, scope);
  let args = options.as_v8(scope);
  let bootstrap_fn = self.bootstrap_fn_global.take().unwrap();
  let bootstrap_fn = v8::Local::new(scope, bootstrap_fn);
  let undefined = v8::undefined(scope);
  bootstrap_fn.call(scope, undefined.into(), &[args]);
}

The bootstrap options are first placed into OpState (making them available to ops), then serialized to a V8 value and passed directly to the JavaScript bootstrapMainRuntime() function. This function was extracted from 99_main.js during snapshot creation and stored as a v8::Global<v8::Function>.

sequenceDiagram
    participant Rust as MainWorker (Rust)
    participant OpState as OpState
    participant V8 as V8 Scope
    participant JS as 99_main.js

    Rust->>OpState: Put BootstrapOptions
    Rust->>V8: Create scope
    Rust->>V8: options.as_v8(scope)
    Note over V8: Serialize Deno version,<br/>location, unstable features,<br/>inspect flag, etc.
    Rust->>V8: bootstrap_fn.call(scope, args)
    V8->>JS: bootstrapMainRuntime(runtimeOptions)
    JS->>JS: Configure console for serve mode
    JS->>JS: Register main module handler
    JS->>JS: Set up globalThis properties
    JS->>JS: Configure error formatting
    JS->>JS: Set hasBootstrapped = true
    JS-->>V8: return
    V8-->>Rust: bootstrap complete

The take().unwrap() on the bootstrap function is intentional — the function should only be called once. If someone tries to call bootstrap twice, the None value will cause a panic with the error "Worker runtime already bootstrapped" from the JavaScript side.

Assembling the Deno Namespace

The runtime/js/90_deno_ns.js file constructs the Deno global namespace by importing from extension modules:

import * as fs from "ext:deno_fs/30_fs.js";
import * as net from "ext:deno_net/01_net.js";
import * as process from "ext:deno_process/40_process.js";
import * as permissions from "ext:runtime/10_permissions.js";
import * as kv from "ext:deno_kv/01_db.ts";
import * as telemetry from "ext:deno_telemetry/telemetry.ts";
// ... 30+ imports

const denoNs = {
  writeFileSync: fs.writeFileSync,
  writeFile: fs.writeFile,
  readTextFile: fs.readTextFile,
  readFile: fs.readFile,
  watchFs: fsEvents.watchFs,
  chmod: fs.chmod,
  cwd: fs.cwd,
  // ... 100+ API bindings
};

Each ext: import references a JavaScript file bundled with an extension (as seen in Article 2). The denoNs object is a flat namespace object — no prototype chain, no classes, just function references and lazy getters.

graph TD
    subgraph "Extension Sources"
        FS["ext:deno_fs/30_fs.js"]
        NET["ext:deno_net/01_net.js"]
        PROC["ext:deno_process/40_process.js"]
        KV["ext:deno_kv/01_db.ts"]
        CRON["ext:deno_cron/01_cron.ts"]
        FETCH["ext:deno_fetch/22_http_client.js"]
        PERM["ext:runtime/10_permissions.js"]
    end

    subgraph "Deno Namespace"
        DNS["Deno.readFile()<br/>Deno.writeFile()<br/>Deno.cwd()"]
        DNN["Deno.connect()<br/>Deno.listen()"]
        DNP["Deno.run()<br/>Deno.Command"]
        DNK["Deno.openKv()"]
        DNC["Deno.cron()"]
        DNH["Deno.createHttpClient()"]
        DNPERM["Deno.permissions"]
    end

    FS --> DNS
    NET --> DNN
    PROC --> DNP
    KV --> DNK
    CRON --> DNC
    FETCH --> DNH
    PERM --> DNPERM

Unstable APIs are gated by feature IDs. During bootstrap, the unstableFeatures array from BootstrapOptions determines which unstable APIs are exposed. If a feature isn't enabled, its APIs remain undefined on the Deno object.

Tip: The numbered prefixes on JS files (01_, 02_, 30_, 90_, 99_) control load order. 90_deno_ns.js imports from everything else, and 99_main.js imports from 90_deno_ns.js. This convention predates ES module support in extensions and is maintained for clarity.

The Permissions System

Deno's "secure by default" model is enforced by a 8-type permission system defined in runtime/permissions/lib.rs. The permission types cover every system resource an op might access:

Permission Flag Protects
Read --allow-read File/directory read access
Write --allow-write File/directory write access
Net --allow-net Network connections
Env --allow-env Environment variable access
Run --allow-run Subprocess execution
FFI --allow-ffi Foreign function interface
Sys --allow-sys System information queries
Import --allow-import Remote module imports

The PermissionFlags struct shows the three-tier design:

pub struct PermissionFlags {
  pub allow_all: bool,
  pub allow_env: Option<Vec<String>>,
  pub deny_env: Option<Vec<String>>,
  pub ignore_env: Option<Vec<String>>,
  pub allow_read: Option<Vec<String>>,
  pub deny_read: Option<Vec<String>>,
  pub ignore_read: Option<Vec<String>>,
  // ... similar for net, run, ffi, sys, write
}

The deny_* flags take precedence over allow_* — you can allow all network access but deny a specific host. The ignore_* flags suppress the interactive prompt for specific resources, which is useful in CI environments.

flowchart TD
    OP["Op requests permission<br/>e.g., read /etc/passwd"]
    DENY{Denied by<br/>--deny-read?}
    DENY -->|Yes| BLOCKED["❌ PermissionDenied"]
    DENY -->|No| ALLOW{Allowed by<br/>--allow-read?}
    ALLOW -->|Yes| GRANTED["✅ Granted"]
    ALLOW -->|No| IGNORE{Ignored by<br/>--ignore-read?}
    IGNORE -->|Yes| BLOCKED
    IGNORE -->|No| BROKER{Permission<br/>broker active?}
    BROKER -->|Yes| IPC["Ask broker via IPC"]
    BROKER -->|No| TTY{Interactive<br/>terminal?}
    TTY -->|Yes| PROMPT["🔐 Prompt user<br/>Allow? [y/n/A]"]
    TTY -->|No| BLOCKED
    PROMPT -->|Allow| GRANTED
    PROMPT -->|Allow All| GRANT_ALL["✅ Grant for type"]
    PROMPT -->|Deny| BLOCKED
    IPC -->|Allow| GRANTED
    IPC -->|Deny| BLOCKED

Permission checks happen at the Rust op boundary — the same ops we traced in Article 2. Every filesystem op calls state.borrow::<PermissionsContainer>() to check permissions before touching the disk. This ensures that no code path can bypass the permission check, regardless of how it was invoked.

The OtelAuditFn type hint in the permissions library reveals another feature: OpenTelemetry audit trails. When OTEL is configured, every permission check — whether granted or denied — is emitted as a telemetry event, creating an audit log of what a program attempted to access.

Permission Broker: Experimental IPC-Based Management

The maybe_setup_permission_broker() function reveals an experimental feature: delegating permission decisions to an external process via IPC:

fn maybe_setup_permission_broker() {
  let Ok(socket_path) = std::env::var("DENO_PERMISSION_BROKER_PATH") else {
    return;
  };
  log::warn!("{} Permission broker is an experimental feature", 
    colors::yellow("Warning"));
  let broker = PermissionBroker::new(socket_path);
  deno_runtime::deno_permissions::broker::set_broker(broker);
}

When the DENO_PERMISSION_BROKER_PATH environment variable is set, Deno connects to a Unix domain socket and sends permission requests to an external process. This enables scenarios where a parent process (like an IDE or orchestrator) manages permissions centrally for multiple Deno processes.

sequenceDiagram
    participant Deno as Deno Process
    participant Broker as Permission Broker
    
    Note over Deno: DENO_PERMISSION_BROKER_PATH set
    Deno->>Broker: Connect via Unix socket
    Deno->>Deno: Op requests net permission
    Deno->>Broker: Check: net, "example.com:443"
    Broker->>Broker: Apply policy
    Broker-->>Deno: Allow / Deny
    Deno->>Deno: Grant or reject op

The broker integration is checked in the permission flow before the interactive prompt — if a broker is active, it takes over permission decisions entirely.

CliMainWorker: Coverage, Profiling, and HMR

The MainWorker from runtime/ is a general-purpose worker. The CLI wraps it in CliMainWorker to add CLI-specific features:

pub struct CliMainWorkerOptions {
  pub create_hmr_runner: Option<CreateHmrRunnerCb>,
  pub maybe_coverage_dir: Option<PathBuf>,
  pub maybe_cpu_prof_config: Option<CpuProfilerConfig>,
  pub default_npm_caching_strategy: NpmCachingStrategy,
  pub needs_test_modules: bool,
}

pub struct CliMainWorker {
  worker: LibMainWorker,
  shared: Arc<SharedState>,
}

The run() method wraps module execution with optional coverage collection, CPU profiling, and HMR (hot module replacement). These are wrapped in Rc<RefCell<Option<...>>> so both the normal exit path and the Deno.exit() op path can flush data — whichever runs first takes the value, preventing double-flush.

Tip: The LibMainWorker intermediate type (from deno_lib) allows the standalone binary to use the same worker logic without the full CLI dependency. It's another example of the extraction pattern we noted in Article 1.

What's Next

We've now seen the complete lifecycle of a Deno worker: creation with generic service options, bootstrap across the Rust-V8-JavaScript boundary, namespace assembly from extension modules, and permission enforcement at the op layer. In the final article, we'll explore Deno's integrated toolchain — the cli/tools/ directory powering fmt, lint, test, compile, and more — along with the deep Node.js compatibility layer in ext/node/, the LSP architecture, and npm integration.