Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,4 @@ packages/*/webui.node
publish/
*.nupkg
.last
packages/webui/bin/webui
packages/webui/bin/webui.exe
3 changes: 2 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1523,7 +1523,8 @@ output names.
### npm Distribution

The `@microsoft/webui` npm package follows the esbuild single-package model:
- `bin: { "webui": "bin/webui" }` — CLI binary via platform-specific `optionalDependencies`
- `bin: { "webui": "bin/webui" }` — a Node launcher that resolves the CLI binary from the matching platform-specific `optionalDependencies` package at runtime
- Platform packages declare platform-suffixed `bin` entries (for example, `webui-darwin-arm64`) so npm publishes their native CLI files with executable permissions.
- `main: "lib/main.js"` — Programmatic API that loads the `.node` native addon directly
- WASM fallback for render when native addon is unavailable (one-time warning logged)

Expand Down
3 changes: 3 additions & 0 deletions docs/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,9 @@ Each route handler should return only the state that route's component needs:

Add `@microsoft/webui-router` if using client-side navigation.

The `webui` npm command is a launcher that resolves the native CLI from the
matching platform-specific optional dependency at runtime.

## Language Integration (Server Side)

WebUI renders from **any** backend. The server loads `protocol.bin` once
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Run the development server with `npm run dev` and build for production with `npm

### Cross-Platform Support

The npm package uses platform-specific optional dependencies to deliver native binaries. Supported platforms are installed automatically - no Rust toolchain required.
The npm package uses platform-specific optional dependencies to deliver native binaries. The `webui` command is a launcher that selects the package for the current OS and CPU at runtime. Supported platforms are installed automatically - no Rust toolchain required.

## Rust

Expand Down
3 changes: 3 additions & 0 deletions packages/webui-darwin-arm64/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"cpu": [
"arm64"
],
"bin": {
"webui-darwin-arm64": "bin/webui"
},
"description": "WebUI platform-specific binary for macOS ARM64",
"files": [
"bin/webui",
Expand Down
3 changes: 3 additions & 0 deletions packages/webui-darwin-x64/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"cpu": [
"x64"
],
"bin": {
"webui-darwin-x64": "bin/webui"
},
"description": "WebUI platform-specific binary for macOS x64",
"files": [
"bin/webui",
Expand Down
3 changes: 3 additions & 0 deletions packages/webui-linux-arm64/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"cpu": [
"arm64"
],
"bin": {
"webui-linux-arm64": "bin/webui"
},
"description": "WebUI platform-specific binary for Linux ARM64",
"files": [
"bin/webui",
Expand Down
3 changes: 3 additions & 0 deletions packages/webui-linux-x64/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"cpu": [
"x64"
],
"bin": {
"webui-linux-x64": "bin/webui"
},
"description": "WebUI platform-specific binary for Linux x64",
"files": [
"bin/webui",
Expand Down
3 changes: 3 additions & 0 deletions packages/webui-win32-arm64/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"cpu": [
"arm64"
],
"bin": {
"webui-win32-arm64": "bin/webui.exe"
},
"description": "WebUI platform-specific binary for Windows ARM64",
"files": [
"bin/webui.exe",
Expand Down
3 changes: 3 additions & 0 deletions packages/webui-win32-x64/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"cpu": [
"x64"
],
"bin": {
"webui-win32-x64": "bin/webui.exe"
},
"description": "WebUI platform-specific binary for Windows x64",
"files": [
"bin/webui.exe",
Expand Down
4 changes: 2 additions & 2 deletions packages/webui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ const { templates, templateFunctions, templateStyles, inventory } = JSON.parse(j

## CLI

The package also includes the `webui` CLI binary:
The package also includes the `webui` CLI launcher:

```bash
# Build templates to disk
Expand All @@ -122,7 +122,7 @@ npx webui inspect ./dist/protocol.bin
| Linux | x64 | `@microsoft/webui-linux-x64` |
| Linux | arm64 | `@microsoft/webui-linux-arm64` |

Platform-specific packages are installed automatically as optional dependencies.
Platform-specific packages are installed automatically as optional dependencies. The CLI launcher selects the matching platform package at runtime.

## License

Expand Down
30 changes: 30 additions & 0 deletions packages/webui/bin/webui
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env node
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { spawnSync } from "node:child_process";
import { platformKey, resolve } from "../dist/platform.js";

const binPath = resolve("bin");

if (!binPath) {
console.error(
`[webui] No CLI binary is available for ${platformKey()}. ` +
"Install optional dependencies, or set WEBUI_BINARY_PATH to a custom binary.",
);
process.exit(1);
}

const result = spawnSync(binPath, process.argv.slice(2), { stdio: "inherit" });

if (result.error) {
console.error(`[webui] Failed to run ${binPath}: ${result.error.message}`);
process.exit(1);
}

if (result.signal) {
console.error(`[webui] CLI process terminated by signal ${result.signal}`);
process.exit(1);
}

process.exit(result.status ?? 1);
2 changes: 1 addition & 1 deletion packages/webui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"build": "tsc",
"postbuild": "node dist/install.js",
"postinstall": "node --input-type=module -e \"try{await import('./dist/install.js')}catch{}\"",
"test": "tsc --project tsconfig.test.json && node --test dist/test/integration.test.js"
"test": "tsc --project tsconfig.test.json && node --test \"dist/test/*.test.js\""
},
"engines": {
"node": ">=18"
Expand Down
20 changes: 8 additions & 12 deletions packages/webui/src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,19 @@
// Licensed under the MIT license.

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolve, platformKey, packageName } from "./platform.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const binDir = path.resolve(__dirname, "..", "bin");
const binName = process.platform === "win32" ? "webui.exe" : "webui";
const binDest = path.join(binDir, binName);

// Locate the platform binary and copy it into bin/ so the package.json
// "bin" entry points at a real native executable.
// The package.json "bin" entry is a JavaScript launcher. Do not copy a
// host-native binary into this package during publish; just verify and repair
// the platform package binary when lifecycle scripts are enabled.
try {
if (process.env["WEBUI_BINARY_PATH"]) {
process.exit(0);
}

const srcBin = resolve("bin");
if (srcBin && fs.existsSync(srcBin)) {
fs.mkdirSync(binDir, { recursive: true });
fs.copyFileSync(srcBin, binDest);
fs.chmodSync(binDest, 0o755);
fs.chmodSync(srcBin, 0o755);
process.exit(0);
}
} catch {
Expand Down
23 changes: 23 additions & 0 deletions packages/webui/test/launcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { spawnSync } from "node:child_process";
import { strict as assert } from "node:assert";
import { describe, test } from "node:test";
import { fileURLToPath } from "node:url";

describe("CLI launcher", () => {
test("dispatches to WEBUI_BINARY_PATH", () => {
const launcher = fileURLToPath(new URL("../../bin/webui", import.meta.url));
const result = spawnSync(process.execPath, [launcher, "--version"], {
encoding: "utf8",
env: {
...process.env,
WEBUI_BINARY_PATH: process.execPath,
},
});

assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout.trim(), /^v\d+\./);
});
});
59 changes: 59 additions & 0 deletions xtask/src/publish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
use crate::util::{build_command, run_command_quiet};
use crate::version;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

Expand Down Expand Up @@ -480,6 +482,7 @@ fn stage_platform(root: &Path, platform: &PlatformEntry, build_dir: &Path) -> bo
.join("native"),
dest_name: platform.ffi_lib,
label: "nuget",
executable: false,
});

// npm: CLI binary → packages/webui-{platform}/bin/
Expand All @@ -488,6 +491,7 @@ fn stage_platform(root: &Path, platform: &PlatformEntry, build_dir: &Path) -> bo
dest_dir: &root.join("packages").join(platform.npm_package).join("bin"),
dest_name: platform.cli_binary,
label: "npm cli",
executable: true,
});

// npm: Node addon (renamed to webui.node)
Expand All @@ -496,6 +500,7 @@ fn stage_platform(root: &Path, platform: &PlatformEntry, build_dir: &Path) -> bo
dest_dir: &root.join("packages").join(platform.npm_package),
dest_name: "webui.node",
label: "npm addon",
executable: false,
});

// publish/native/: CLI binary with platform suffix for direct download
Expand All @@ -505,6 +510,7 @@ fn stage_platform(root: &Path, platform: &PlatformEntry, build_dir: &Path) -> bo
dest_dir: &root.join("publish").join("native"),
dest_name: &native_name,
label: "native",
executable: true,
});

ok
Expand Down Expand Up @@ -801,6 +807,7 @@ struct CopySpec<'a> {
dest_dir: &'a Path,
dest_name: &'a str,
label: &'a str,
executable: bool,
}

fn stage_file(spec: &CopySpec<'_>) -> bool {
Expand Down Expand Up @@ -838,6 +845,19 @@ fn stage_file(spec: &CopySpec<'_>) -> bool {
return false;
}

if spec.executable {
if let Err(e) = make_executable(&dest) {
eprintln!(
" {} [{}] chmod failed: {}: {}",
console::style("✘").red().bold(),
spec.label,
dest.display(),
e,
);
return false;
}
}

let rel = dest
.strip_prefix(std::env::current_dir().as_deref().unwrap_or(Path::new("")))
.unwrap_or(&dest);
Expand All @@ -850,6 +870,21 @@ fn stage_file(spec: &CopySpec<'_>) -> bool {
true
}

#[cfg(unix)]
fn make_executable(path: &Path) -> Result<(), String> {
let mut permissions = fs::metadata(path)
.map_err(|e| format!("failed to read permissions: {e}"))?
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions)
.map_err(|e| format!("failed to set executable permissions: {e}"))
}

#[cfg(not(unix))]
fn make_executable(_path: &Path) -> Result<(), String> {
Ok(())
}

fn resolve_build_dir(root: &Path, triple: &str, profile: &str) -> PathBuf {
let cross = root.join("target").join(triple).join(profile);
if cross.exists() {
Expand Down Expand Up @@ -1094,6 +1129,30 @@ mod tests {
assert!(!dest.path().join("other.txt").exists());
}

#[cfg(unix)]
#[test]
fn stage_file_sets_executable_permissions_when_requested() {
let src = tempfile::TempDir::new().unwrap();
let dest = tempfile::TempDir::new().unwrap();
let src_file = src.path().join("webui");
fs::write(&src_file, "binary").unwrap();

assert!(stage_file(&CopySpec {
src: &src_file,
dest_dir: dest.path(),
dest_name: "webui",
label: "test",
executable: true,
}));

let mode = fs::metadata(dest.path().join("webui"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o755);
}

#[test]
fn test_copy_directory_contents_preserves_subdirectories() {
let src = tempfile::TempDir::new().unwrap();
Expand Down
Loading