@base44/vite-plugin
Vite plugin for Base44 applications running in sandboxed iframes. Provides error tracking, HMR notifications, visual editing, navigation tracking, and legacy SDK compatibility.
Installation
npm install @base44/vite-plugin
Usage
// vite.config.ts
import base44 from "@base44/vite-plugin";
export default {
plugins: [
base44({
legacySDKImports: false,
hmrNotifier: true,
navigationNotifier: true,
visualEditAgent: true,
}),
],
};
Options
| Option | Type | Default | Description |
|---|---|---|---|
legacySDKImports | boolean | false | Enable legacy SDK import resolution via compat layer |
hmrNotifier | boolean | false | Notify parent window of HMR update lifecycle |
navigationNotifier | boolean | false | Track URL changes and notify parent window |
visualEditAgent | boolean | false | Enable interactive visual element editing |
Architecture
Plugin Composition
The plugin registers these sub-plugins when running in a sandbox (MODAL_SANDBOX_ID is set):
- base44 — Core configuration: path aliases (
@/→/src/), environment variables, dependency optimization, and legacy SDK import resolution. - iframe-hmr — Sets CORS and
frame-ancestorsheaders to allow iframe embedding. - error-overlay — Replaces Vite's default error overlay with a custom one that reports errors to the parent window. Under Vite 8's bundled dev mode (
experimental.bundledDev) Vite's client is never served, so the plugin turns the overlay off instead andsandbox-build-error-notifierforwards build errors to the parent in the sameapp_errorshape. - html-injections — Injects sandbox-side scripts into the HTML while serving. Under bundled dev mode its sibling html-injections-bundled injects them instead, before the HTML is bundled.
- html-injections-production — Injects the analytics tracker into production builds.
Dev and production are told apart by the Vite command (serve vs build), not by the presence of a dev server in the HTML transform context — bundled dev runs the HTML through the build-shaped transform, where no server is passed.
Outside a sandbox, only the core plugin runs (with optional API proxy support via VITE_BASE44_APP_BASE_URL).
Cloudflare Workers compatibility
base44-workerd-compat adjusts only environments that resolve with the workerd
condition, which Cloudflare's Vite plugin uses for server code. The browser bundle
and Node-run servers are untouched. It covers both Vite's pipeline and the dev
dependency optimizer, for esbuild (Vite 7) and Rolldown (Vite 8):
wsresolves tocompat/workerd-ws.cjs, aws-shaped client over workerd's native WebSocket. The package's own browser build only throws.WebSocketServerthrows too: server code can open sockets but not listen.AsyncLocalStorage.enterWith(), which always throws in workerd, becomes a no-op. A module that calls it first importscompat/workerd-enter-with.mjs. The no-op is global to the isolate: any code that relies onenterWith()to propagate state gets nothing, silently, instead of an exception. Userun().createRequire()in dev gets its argument's query string dropped. Optimized dependencies carry Vite's?v=query, which workerd rejects. Builds are left byte-for-byte alone.
Stagehand 3 needs all three. @base44/functions-compiler applies the first two to
backend functions. Cloudflare's nodejs_compat flag does not cover any of them:
its node:http client cannot upgrade a connection, which ws needs, enterWith()
throws by design, and createRequire() rejects queries.
Build-status endpoint
An opt-in, dev-only (apply: "serve") endpoint that reports the last build /
compile / HMR error observed by the dev server. It exists solely to let the
preview backend poll for post-load errors — it is never part of a
production build.
Enable it by setting the environment variable at dev-server start:
BASE44_BUILD_STATUS_ENABLED=1
The env var is checked when the plugin assembles its plugin list (config load),
so the generated app's vite.config.js does not need to change. When it is
unset, the build-status sub-plugin is not added to the pipeline at all — its
configureServer hook is never reached.
When enabled, the plugin serves:
GET /__build_status
-
Loopback-only. Requests from any non-loopback
remoteAddress(127.0.0.1,::1,::ffff:127.0.0.1) fall through as if the route did not exist. Non-GETmethods fall through too. -
Always
200. Health lives in the body, not the status code — a500would trip the preview proxy's own error path. The body is:{ "ok": true, "error": null }or, when an error is outstanding:
{ "ok": false, "error": { "message": "…", "frame": "…", "loc": { "…": 0 } } } -
Scrubbed. Even though the endpoint is loopback-only (the backend relays a trimmed form onward), the error is size-capped and absolute filesystem paths are stripped down to basenames.
-
Fail-open. All setup and request handling is wrapped in
try/catchso a bug in this plugin can never break the dev server.
How it works: configureServer wraps the HMR send channel
(server.hot ?? server.ws). An error payload records the last error; a
successful update or full-reload clears it.
Under bundled dev mode failures never reach that channel — Vite reports them
through the client environment's logger and per-client sends — so the plugin
additionally records the error the logger is given, plus a failed buildEnd,
and clears it when a rebuild reaches generateBundle. Boot-time build
failures are therefore reported in this mode.
Cold-start caveat & scope. Errors thrown before any client has connected
are not sent over the HMR channel, so a boot-time transform failure may briefly
read ok: true. That window is already covered server-side by the existing
dev_server_error_classifier (install / start failures surface as 409). This
endpoint's job is post-load HMR / compile errors, not cold-start failures.
Route request logs (full-stack apps)
Dev-only. While a Nitro server route serves a request that carries
X-B44-Request-Id, every console.* line it prints goes to stdout prefixed
[b44-req <id> <level>] , followed by one summary line per request
(GET /api/orders handled in 12ms, or threw <Error> after 12ms). Requests
without the header are untouched. It ships as a Nitro module (the nitro
property on base44-route-request-log) because Nitro runs dev routes in a
separate runtime whose console a Vite middleware never sees.
Sandbox Injections
In dev mode, individual <script type="module" src="..."> tags are injected for each feature, loaded directly from node_modules. Under bundled dev mode the tags are injected with order: "pre" instead, so they become part of the HTML entry and are folded into the bundle (with import.meta.hot available). Their src is then a virtual:base44-vite-plugin/... id that the plugin resolves onto its own dist/ directory, so the bundle does not depend on where npm placed the package.
- Error handlers (
unhandled-errors-handlers.js) — Globalerrorandunhandledrejectionlisteners that report to the parent viapostMessage. - Mount observer (
sandbox-mount-observer.js) —MutationObserverthat detects when instrumented elements (data-source-location) are rendered. - HMR notifier (
sandbox-hmr-notifier.js) — Forwards Vite'sbeforeUpdate/afterUpdateevents to the parent. - Navigation notifier (
navigation-notifier.js) — InterceptspushState,replaceState, andpopstateto track URL changes. - Build error notifier (
sandbox-build-error-notifier.js, bundled dev only) — Forwardsvite:errorpayloads to the parent asapp_error, replacing the custom error overlay that cannot be installed in that mode. - HMR socket relay (
sandbox-hmr-socket-relay.js, bundled dev only) — Setswindow.__base44_bundled_dev__and postsSANDBOX_WS_CONNECT/SANDBOX_WS_DISCONNECT/sandbox:beforeFullReload, the events the preview proxy's bridge otherwise collects by importing/@vite/client.
These are self-executing scripts — they run as soon as the browser loads them, with no setup call required.
The visual edit agent is the exception. It's a larger module (~560 lines) that is bundled separately via tsup into dist/statics/index.mjs and loaded via a dynamic import(). This allows local dev iteration: add ?sandbox-bridge=local to load it from a local HTTPS dev server instead of node_modules. Bundled dev mode mounts no static middleware, so in that mode the plugin serves its own dist/ tree (scripts and source maps only) for this import.
In production, an inline analytics tracker script is injected instead.
Server-rendered documents (base44:document)
transformIndexHtml never runs for an app whose HTML document is rendered by the framework (TanStack Start and other SSR setups have no index.html). Those apps import the same scripts as a module and render them in <body>, before the framework's own scripts:
import { Base44Scripts } from "base44:document";
function RootDocument({ children }) {
return (
<html>
<head><HeadContent /></head>
<body data-react-root="true">
{children}
<Base44Scripts />
<Scripts />
</body>
</html>
);
}
The module is virtual: the plugin decides its contents from the same options, command and env as the index.html injections, so dev gets the sandbox scripts (plus ssr-mount-signal.js, which reports the mount once the document is parsed because a hydrated page never triggers the mutation observer) and production gets the analytics tracker. Module scripts run in document order, so rendering the component before <Scripts /> keeps the error handlers registered before hydration starts, as they are in index.html. Never render it in <head>: the preview proxy prepends its own scripts there, and React 19 fails hydration of a head it did not render. base44DocumentScripts() returns the raw { src, classic? } / { children } list, typed as DocumentScript from @base44/vite-plugin, for a document that renders the tags itself. Render every entry as type="module" except those with classic: true: the Tailwind CDN that the sandbox dev server loads for visual edit is a classic script.
Local Development (Visual Edit Agent)
To iterate on the visual edit agent locally without publishing:
-
Generate local HTTPS certificates:
mkcert localhost -
Start the dev server (with optional watch mode):
npm run dev # serve dist/statics/ on https://localhost:3201 npm run dev -- -w # same, with auto-rebuild on changes -
Add
?sandbox-bridge=localto your app's URL to load the agent fromlocalhost:3201instead ofnode_modules.
Legacy SDK Compatibility
When legacySDKImports is enabled, imports of /entities, /functions, /integrations, and /agents are resolved to compatibility modules in compat/. These use Proxy objects to route calls to the Base44 SDK client.
Building
npm run build # TypeScript compilation (plugin)
npm run build:bridge # tsup bundle (bridge ES module for browsers)
Server-side static-egress TCP
The server-only base44:runtime module exports staticEgress.connect(address, options?). It delegates to the TCP helper installed by Base44's full-stack
upload wrapper; Vite does not open sockets or read workspace configuration at
build time. A deployment missing that helper rejects with a rebuild/redeploy
error. Local development without the injected helper does not fall back to
direct TCP.
import { staticEgress } from "base44:runtime";
const socket = await staticEgress.connect({ hostname: "sftp.example.com", port: 2222 });
// Use the streams with a compatible SSH client; preserve host-key verification.
await socket.close();
Requires the companion platform TCP runtime and an enabled workspace static-egress
configuration. Rebuild after upgrading this plugin. Currently only raw TCP
(secureTransport: "off", the default) is supported; SSH provides its own
encryption. TLS and STARTTLS options reject until explicitly supported.
Private data sources (base44:private-data-sources/*)
Server code reaches a workspace private data source with the same imports a
backend function uses: postgres, mysql, mariadb, http, elasticsearch,
sqlserver, redis, mongodb and oracle, each from
base44:private-data-sources/<type>. The modules are this package's copy of the
backend-function adapters, reading the worker's bindings and
BASE44_PRIVATE_DATA_SOURCES manifest off env from cloudflare:workers.
import { Connection } from "tedious";
import { sqlserver } from "base44:private-data-sources/sqlserver";
const source = sqlserver("My-SQL-Server");
const connection = new Connection({
server: source.host,
authentication: { type: "default", options: { userName: source.username, password: source.password } },
options: { database: source.database, encrypt: false, connector: source.tediousConnector() },
});
The bindings exist only on the deployed worker. In local development and the editor preview a lookup throws, saying the source is available on the published app. A client-side import fails the build, since a source carries credentials.
License
MIT