Native IO Bridge
bext's V8 runtime provides native IO capabilities through global bridge functions. These are registered by the Rust server before your bundle is evaluated, giving your JavaScript code synchronous access to the filesystem, HTTP, SQLite, environment variables, and the logging system.
Why a bridge?
V8 is a sandboxed JavaScript engine — it has no built-in filesystem, network, or database access. The bridge functions are Rust-implemented globals that bext injects into the V8 isolate. They are synchronous because render workers process one request at a time (parallelism comes from the worker pool, not async IO).
┌─────────────────────────────────────────────┐
│ Your JavaScript (V8 Worker) │
│ │
│ __readFile("./data.json") │
│ │ │
│ ▼ │
│ Rust Bridge (native function) │
│ │ │
│ ▼ │
│ Kernel (actual file read) │
│ │ │
│ ▼ │
│ Returns UTF-8 string to JS │
└─────────────────────────────────────────────┘
Raw globals
These are available directly on globalThis in any bext V8 bundle:
__readFile(path)
Read a file's contents as a UTF-8 string. Throws if the file does not exist.
const content = __readFile("./data/products.json");
const products = JSON.parse(content);
__readFileExists(path)
Check if a file or directory exists. Returns true or false.
if (__readFileExists("./data/cache.json")) {
const cached = JSON.parse(__readFile("./data/cache.json"));
}
__readDir(path)
List directory contents. Returns a JSON-encoded array of filenames.
const files: string[] = JSON.parse(__readDir("./content/posts"));
// ["2026-01-hello.md", "2026-02-world.md"]
__httpFetch(url, optsJson?)
Synchronous HTTP fetch. Returns a JSON-encoded response object.
const response = JSON.parse(__httpFetch("https://api.example.com/data"));
// { ok: true, status: 200, body: "{...}" }
// With options
const response = JSON.parse(__httpFetch("https://api.example.com/data", JSON.stringify({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: "value" }),
timeout_ms: 5000,
})));
__dbQuery(dbPath, sql, paramsJson?)
Execute a SQLite query. Returns JSON with either rows (for SELECT) or changes (for INSERT/UPDATE/DELETE).
// SELECT query
const result = JSON.parse(__dbQuery("./data.db", "SELECT * FROM products WHERE active = ?", "[true]"));
// { rows: [{ id: 1, name: "Widget", active: true }], columns: ["id", "name", "active"] }
// INSERT query
const result = JSON.parse(__dbQuery("./data.db", "INSERT INTO products (name) VALUES (?)", '["New Product"]'));
// { changes: 1, last_insert_rowid: 42 }
__env(key)
Read an environment variable. Returns the value as a string, or null if not set.
const apiKey = __env("API_KEY");
const nodeEnv = __env("NODE_ENV"); // null if not set
__log(level, msg)
Log a message through Rust's tracing system. Messages appear in bext's structured log output.
__log("info", "Processing request for /api/products");
__log("error", "Failed to connect to database");
__log("debug", "Cache miss for key: products:123");
__log("warn", "Rate limit approaching for client 192.168.1.1");
Typed access via @bext-stack/framework/bridge
The @bext-stack/framework/bridge module wraps the raw globals with TypeScript types and JSON parsing:
readFile,
fileExists,
readDir,
httpFetch,
dbQuery,
env,
log,
} from "@bext-stack/framework/bridge";
readFile(path)
const content: string = readFile("./data/config.json");
fileExists(path)
const exists: boolean = fileExists("./data/cache.json");
readDir(path)
Returns a parsed string[] instead of raw JSON:
const files: string[] = readDir("./content/posts");
files.forEach((file) => {
const content = readFile(`./content/posts/${file}`);
// process each file
});
httpFetch(url, opts?)
Returns a parsed response object:
const response = httpFetch("https://api.github.com/repos/benfavre/bext", {
headers: { "Accept": "application/json" },
timeout_ms: 3000,
});
if (response.ok) {
const repo = JSON.parse(response.body);
console.log(repo.stargazers_count);
} else {
log("error", `GitHub API error: ${response.status}`);
}
Full options type:
interface FetchOptions {
method?: string; // GET, POST, PUT, DELETE, etc.
headers?: Record<string, string>; // Request headers
body?: string; // Request body
timeout_ms?: number; // Timeout in milliseconds
}
interface FetchResponse {
ok: boolean; // true if status 200-299
status: number; // HTTP status code
body: string; // Response body as string
error?: string; // Error message if request failed
}
dbQuery(dbPath, sql, params?)
Returns a parsed result object:
// SELECT
const { rows, columns } = dbQuery("./app.db", "SELECT * FROM users WHERE role = ?", ["admin"]);
// rows: [{ id: 1, name: "Alice", role: "admin" }, ...]
// columns: ["id", "name", "role"]
// INSERT
const { changes, last_insert_rowid } = dbQuery(
"./app.db",
"INSERT INTO users (name, role) VALUES (?, ?)",
["Bob", "user"]
);
// changes: 1, last_insert_rowid: 5
// UPDATE
const { changes } = dbQuery("./app.db", "UPDATE users SET role = ? WHERE id = ?", ["admin", 5]);
// changes: 1
// CREATE TABLE
dbQuery("./app.db", `
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
price REAL NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
env(key)
const apiKey: string | null = env("API_KEY");
const port = env("PORT") ?? "3000";
log(level, msg)
log("info", "Server initialized");
log("warn", `Slow query: ${elapsed}ms`);
log("error", `Database connection failed: ${err.message}`);
log("debug", `Cache lookup: key=${key}, hit=${!!cached}`);
Valid levels: "debug", "info", "warn", "error".
Node fs shim
The @bext-stack/framework/fs module provides a Node.js-compatible subset of fs and path using the bridge globals. This is useful when you have existing code that imports from "fs":
// Same API as Node's fs module
const content = readFileSync("./data/config.json");
const files = readdirSync("./content/posts");
const exists = existsSync("./data/cache.json");
const fullPath = join("./content", "posts", "hello.md");
Aliasing in your build
You can alias "fs" and "path" to the bext shim in your Bun build config so existing libraries work without modification:
// build.ts
await Bun.build({
entrypoints: ["server/entry.ts"],
outdir: "dist",
plugins: [{
name: "fs-shim",
setup(build) {
build.onResolve({ filter: /^(fs|path)$/ }, () => ({
path: require.resolve("@bext-stack/framework/fs"),
}));
},
}],
});
Available functions
| Function | Description |
|---|---|
readFileSync(path) |
Read file contents as UTF-8 string |
readdirSync(path) |
List directory contents as string array |
existsSync(path) |
Check if path exists |
join(...parts) |
Join path segments with / |
Security: path traversal protection
bext's Rust bridge functions do not perform path sandboxing themselves — the file paths are resolved relative to the process working directory. However, bext's WAF (Web Application Firewall) blocks path traversal attacks at the HTTP layer before requests reach your JavaScript:
- URLs containing ../ or ..\\ are rejected with 403
- Null bytes in paths are rejected
- The static file server independently sanitizes paths
For your own route handlers, validate user-provided paths before passing them to bridge functions:
function loadDocument(slug: string): string | null {
// Validate: only allow alphanumeric, hyphens, and forward slashes
if (!/^[a-zA-Z0-9\-\/]+$/.test(slug)) return null;
// Prevent traversal
if (slug.includes("..")) return null;
const path = `./content/docs/${slug}.md`;
if (!fileExists(path)) return null;
return readFile(path);
}
Complete example: blog with SQLite
A blog that reads posts from markdown files and stores view counts in SQLite:
// Initialize database
dbQuery("./blog.db", `
CREATE TABLE IF NOT EXISTS views (
path TEXT PRIMARY KEY,
count INTEGER DEFAULT 0
)
`);
// Load all posts from markdown files
function loadPosts(): Page[] {
const files = readDir("./content/posts").filter((f) => f.endsWith(".md"));
return files.map((file) => {
const raw = readFile(`./content/posts/${file}`);
const title = raw.match(/^#\s+(.+)/m)?.[1] ?? file;
return {
title,
description: "",
html: markdownToHtml(raw),
};
});
}
// Track page views
const trackViews: RouteHandler = (ctx) => {
if (ctx.request.method !== "GET") return null;
if (ctx.path.startsWith("/api/")) return null;
dbQuery("./blog.db",
"INSERT INTO views (path, count) VALUES (?, 1) ON CONFLICT(path) DO UPDATE SET count = count + 1",
[ctx.path]
);
return null; // Continue to next handler
};
// View count API
const viewsApi: RouteHandler = (ctx) => {
if (ctx.path !== "/api/views") return null;
const { rows } = dbQuery("./blog.db", "SELECT path, count FROM views ORDER BY count DESC LIMIT 20");
return json(rows);
};
createSite({
pages: { getPage: (path) => /* ... */, getPagePaths: () => /* ... */ },
template: { renderPage: (page, ctx) => /* ... */ },
hostname: "blog.bext.dev",
routes: [trackViews, viewsApi],
});
log("info", "Blog initialized with SQLite view tracking");