Files
vercel-labs--zero-native/skill-data/core/references/bridge-security-native-capabilities.md
2026-07-13 12:29:49 +08:00

6.2 KiB

Bridge, Security, and Native Capabilities

Use this when adding JavaScript-to-Zig calls, builtin commands, permissions, windows, child WebViews, dialogs, navigation policies, or external links.

Bridge architecture

JavaScript calls native Zig through:

const result = await window.zero.invoke("native.ping", { source: "webview" });

The runtime:

  1. Parses the JSON request.
  2. Enforces message size limits.
  3. Checks origin and permissions.
  4. Looks up a registered handler.
  5. Runs the handler and returns a JSON response.

Bridge commands are default-deny. A command must be registered in Zig and allowed by policy.

Handler pattern

fn ping(context: *anyopaque, invocation: native_sdk.bridge.Invocation, output: []u8) anyerror![]const u8 {
    _ = invocation;
    const self: *App = @ptrCast(@alignCast(context));
    self.ping_count += 1;
    return std.fmt.bufPrint(output, "{{\"message\":\"pong\",\"count\":{d}}}", .{self.ping_count});
}

Dispatcher pattern:

fn bridge(self: *App) native_sdk.BridgeDispatcher {
    self.handlers = .{.{ .name = "native.ping", .context = self, .invoke_fn = ping }};
    return .{
        .policy = .{ .enabled = true, .commands = &policies },
        .registry = .{ .handlers = &self.handlers },
    };
}

When returning user-controlled strings, escape them:

return native_sdk.bridge.writeJsonStringValue(output, user_name);

Size limits

  • Request message: 16 KiB.
  • Response: 16 KiB.
  • Handler result: 12 KiB.
  • Request ID: 64 bytes.
  • Command name: 128 bytes.

For large data, do not force everything through one bridge response. Use native files/resources or chunking patterns.

Security policy

Core defaults:

  • No permissions granted unless listed.
  • Bridge commands denied unless policy allows them.
  • Navigation blocked unless origin is allowlisted.
  • External links denied unless configured.
  • Dialog builtin commands always denied unless explicitly listed in builtin_bridge.

Manifest examples:

.permissions = .{ "window" },
.capabilities = .{ "webview", "js_bridge" },
.bridge = .{
    .commands = .{
        .{ .name = "native.ping", .origins = .{ "zero://app" } },
    },
},
.security = .{
    .navigation = .{
        .allowed_origins = .{ "zero://app", "http://127.0.0.1:5173" },
        .external_links = .{ .action = "deny" },
    },
},

Prefer exact origins over "*". Use "*" only for commands that expose no native state and only when the project already accepts that risk.

Builtin commands

The Native SDK includes builtin bridge commands for windows, layered WebViews, and dialogs. These are controlled separately from app-defined commands via builtin_bridge.

Window commands:

  • native-sdk.window.list
  • native-sdk.window.create
  • native-sdk.window.focus
  • native-sdk.window.close

Layered WebView commands:

  • native-sdk.webview.create
  • native-sdk.webview.list
  • native-sdk.webview.setFrame
  • native-sdk.webview.navigate
  • native-sdk.webview.setZoom
  • native-sdk.webview.setLayer
  • native-sdk.webview.close

Dialog commands:

  • native-sdk.dialog.openFile
  • native-sdk.dialog.saveFile
  • native-sdk.dialog.showMessage

Enable explicitly:

const app_permissions = [_][]const u8{native_sdk.security.permission_window};

.security = .{
    .permissions = &app_permissions,
    .navigation = .{ .allowed_origins = &.{ "zero://app" } },
},
.builtin_bridge = .{
    .enabled = true,
    .commands = &.{
        .{ .name = "native-sdk.window.create", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
        .{ .name = "native-sdk.webview.create", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
        .{ .name = "native-sdk.dialog.openFile", .origins = .{ "zero://app" } },
    },
},

js_window_api = true exposes window.zero.windows.* and window.zero.webviews.*, but it does not bypass origin or permission checks.

Windows from JavaScript

const win = await window.zero.windows.create({
  label: "tools",
  title: "Tools",
  width: 420,
  height: 320,
});

const all = await window.zero.windows.list();
await window.zero.windows.focus(win.id);
await window.zero.windows.close(win.id);

Window state persistence uses stable labels. Use meaningful labels like main, settings, tools, or preview.

Layered WebViews

Child WebViews are native WebViews layered inside a native window:

const preview = await window.zero.webviews.create({
  label: "preview",
  url: "https://example.com",
  frame: { x: 24, y: 24, width: 480, height: 320 },
  layer: 10,
  bridge: false,
});

await preview.setZoom(1.25);
await preview.setLayer(20);
await preview.close();

Rules:

  • WebView URLs must pass navigation policy.
  • Commands target only the calling native window.
  • main is reserved for the startup WebView.
  • Child WebViews receive window.zero only with bridge: true.
  • Backend gaps should reject with invalid_request.

Dialogs

Dialogs require explicit builtin_bridge policy.

const files = await window.zero.invoke("native-sdk.dialog.openFile", {
  title: "Select a file",
  defaultPath: "/home",
  allowMultiple: true,
  allowDirectories: false,
});

const path = await window.zero.invoke("native-sdk.dialog.saveFile", {
  title: "Save as",
  defaultName: "untitled.txt",
});

const result = await window.zero.invoke("native-sdk.dialog.showMessage", {
  style: "warning",
  title: "Confirm",
  message: "Delete this item?",
  primaryButton: "Delete",
  secondaryButton: "Cancel",
});

Use native dialogs for trusted app UI. Do not expose arbitrary filesystem access to remote or untrusted origins.

Error handling

JavaScript bridge calls reject with error.code:

  • invalid_request: malformed input, unsupported operation, denied navigation URL, missing target, duplicate/reserved label.
  • unknown_command: no registered handler.
  • permission_denied: origin or permission failed.
  • handler_failed: handler returned an error.
  • payload_too_large: request too large.
  • internal_error: unexpected runtime failure.

Always handle errors in frontend code:

try {
  await window.zero.invoke("native.save", payload);
} catch (error) {
  console.error(error.code, error.message);
}