Inside the Worker: Bootstrap, the Deno Namespace, and the Permissions System
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.jsimports from everything else, and99_main.jsimports from90_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
LibMainWorkerintermediate type (fromdeno_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.