diff --git a/docs/wasm-runtime-architecture.md b/docs/wasm-runtime-architecture.md
index 27368f0..e831b55 100644
--- a/docs/wasm-runtime-architecture.md
+++ b/docs/wasm-runtime-architecture.md
@@ -131,6 +131,15 @@ __uce_[_]
| `exists` | probe only — resolves the unit, loads nothing |
`sanitize_symbol_suffix()` keeps `[A-Za-z0-9_]` (mirrors `ascii_safe_name`).
+Unit code is hidden by default so the linker can discard loaded helpers that the
+unit does not use. Handler macros and the explicit `EXPORT` directive are the
+only application symbols promoted to default visibility; generated request
+binding and constructor exports remain part of the runtime ABI. Debug sections
+are removed after linking while the wasm name section is retained for runtime
+traces. This keeps each side module's code and public ABI local to its actual
+handler responsibility instead of duplicating every helper from a loaded app
+library.
+
`wasm_resolve_target(unit, handler)` (`src/wasm/core.cpp`) resolves the source
path and looks up the export's funcref slot; `exists` lets callers probe a unit
without instantiating it.
diff --git a/scripts/compile_wasm_unit b/scripts/compile_wasm_unit
index 0f16d02..b04de17 100755
--- a/scripts/compile_wasm_unit
+++ b/scripts/compile_wasm_unit
@@ -20,7 +20,7 @@ PCH_ENABLED=${UCE_WASM_UNIT_PCH:-1}
PCH_DIR=${UCE_WASM_PCH_DIR:-/tmp/uce/wasm-w2/pch}
COMMON_FLAGS=(
--target=wasm32-wasip1
- -fPIC -fvisibility=default -fvisibility-inlines-hidden
+ -fPIC -fvisibility=hidden -fvisibility-inlines-hidden
-O1 -g -std=c++20
# The server captures this script's output and treats any non-empty result as
# a compile failure (then drops the .wasm), so a successful build must be
@@ -80,6 +80,7 @@ fi
-c "$DEST_DIR/$PP_FN" -o "$OBJ_FN"
"$SDK/bin/wasm-ld" -shared --experimental-pic \
+ --gc-sections \
--unresolved-symbols=import-dynamic \
--Bsymbolic \
"$OBJ_FN" -o "$WASM_TMP" \
@@ -92,7 +93,7 @@ fi
--export-if-defined=__uce_once \
--export-if-defined=__uce_init
-"$SDK/bin/llvm-objcopy" --add-section=uce.abi="$ABI_TMP" "$WASM_TMP"
+"$SDK/bin/llvm-objcopy" --strip-debug --add-section=uce.abi="$ABI_TMP" "$WASM_TMP"
python3 scripts/check_unit_wasm.py "$WASM_TMP" --abi-version "$ABI_VERSION" --llvm-nm "$SDK/bin/llvm-nm"
mv "$WASM_TMP" "$DEST_DIR/$WASM_FN"
diff --git a/scripts/run_cli_tests.sh b/scripts/run_cli_tests.sh
index e9e176e..6903cc6 100755
--- a/scripts/run_cli_tests.sh
+++ b/scripts/run_cli_tests.sh
@@ -76,5 +76,6 @@ if [[ "$action" == "run" ]]; then
scripts/test_log_timeliness.sh
scripts/test_raw_http_request_log.sh
scripts/test_component_resolution_ttl.sh
+ scripts/test_unit_export_surface.sh
scripts/test_socket_activation.sh
fi
diff --git a/scripts/test_unit_export_surface.sh b/scripts/test_unit_export_surface.sh
new file mode 100755
index 0000000..9c4d590
--- /dev/null
+++ b/scripts/test_unit_export_surface.sh
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+test_name="unit-export-surface-test-$$"
+site_directory="${UCE_TEST_SITE_DIRECTORY:-site}"
+bin_directory="${UCE_TEST_BIN_DIRECTORY:-/tmp/uce/work}"
+if [[ -r /etc/uce/settings.cfg ]]; then
+ if [[ -z "${UCE_TEST_SITE_DIRECTORY:-}" ]]; then
+ configured_site_directory=$(awk -F= '/^[[:space:]]*HTTP_DOCUMENT_ROOT[[:space:]]*=/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); print $2; exit}' /etc/uce/settings.cfg)
+ site_directory="${configured_site_directory:-$site_directory}"
+ fi
+ if [[ -z "${UCE_TEST_BIN_DIRECTORY:-}" ]]; then
+ configured_bin_directory=$(awk -F= '/^[[:space:]]*BIN_DIRECTORY[[:space:]]*=/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); print $2; exit}' /etc/uce/settings.cfg)
+ bin_directory="${configured_bin_directory:-$bin_directory}"
+ fi
+fi
+source_dir="$site_directory/$test_name"
+artifact_dir=""
+
+cleanup() {
+ rm -rf "$source_dir"
+ if [[ -n "$artifact_dir" ]]; then
+ rm -rf "$artifact_dir"
+ fi
+}
+trap cleanup EXIT
+mkdir -p "$source_dir"
+absolute_source_dir=$(realpath "$source_dir")
+artifact_dir="$bin_directory$absolute_source_dir"
+
+printf '%s\n' \
+ 'String visibility_used() { return("private-used"); }' \
+ 'String visibility_unused() { return("uce-private-unused-marker-8f61d2"); }' \
+ 'EXPORT String visibility_shared() { return("shared"); }' \
+ 'CLI(Request& context) { print(visibility_shared(), ":", visibility_used(), ":", component("named:NAMED", context)); }' \
+ >"$source_dir/entry.uce"
+printf '%s\n' \
+ 'String named_used() { return("private-named-used"); }' \
+ 'String named_unused() { return("uce-named-unused-marker-4ae973"); }' \
+ 'EXPORT String named_shared() { return("named-export"); }' \
+ 'COMPONENT:NAMED(Request& context) { print(named_shared(), ":", named_used()); }' \
+ >"$source_dir/named.uce"
+
+output=$(scripts/uce-cli "/$test_name/entry.uce")
+if [[ "$output" != "shared:private-used:named-export:private-named-used" ]]; then
+ echo "unit export surface runtime failed: $output" >&2
+ exit 1
+fi
+
+entry_wasm="$artifact_dir/entry.uce.wasm"
+named_wasm="$artifact_dir/named.uce.wasm"
+python3 - "$entry_wasm" "$named_wasm" <<'PY'
+import sys
+from pathlib import Path
+
+from scripts.check_unit_wasm import collect
+
+cases = [
+ (Path(sys.argv[1]), {"__wasm_call_ctors", "__uce_set_current_request", "__uce_cli", "visibility_shared"}, b"uce-private-unused-marker-8f61d2"),
+ (Path(sys.argv[2]), {"__wasm_call_ctors", "__uce_set_current_request", "__uce_component_NAMED", "named_shared"}, b"uce-named-unused-marker-4ae973"),
+]
+for path, allowed, unused_marker in cases:
+ data = path.read_bytes()
+ _, imports, exports = collect(path)
+ names = {name for name, _ in exports}
+ unexpected = names - allowed
+ missing = allowed - names
+ if unexpected or missing:
+ raise SystemExit(f"{path}: unexpected exports={sorted(unexpected)} missing={sorted(missing)} all={sorted(names)}")
+ if unused_marker in data:
+ raise SystemExit(f"{path}: retained unused private code marker")
+ if len(imports) >= 40:
+ raise SystemExit(f"{path}: retained {len(imports)} imports (expected fewer than 40)")
+ if path.stat().st_size >= 1024 * 1024:
+ raise SystemExit(f"{path}: artifact is {path.stat().st_size} bytes (expected under 1 MiB)")
+PY
+
+echo "unit export surface passed"
diff --git a/src/lib/compiler.cpp b/src/lib/compiler.cpp
index b43fff2..ad4ea01 100644
--- a/src/lib/compiler.cpp
+++ b/src/lib/compiler.cpp
@@ -16,7 +16,7 @@
namespace {
-const u64 UCE_UNIT_ABI_VERSION = 8;
+const u64 UCE_UNIT_ABI_VERSION = 9;
struct SharedUnitFilesystemState
{
diff --git a/src/lib/compiler.h b/src/lib/compiler.h
index 84115d1..9f4bd10 100644
--- a/src/lib/compiler.h
+++ b/src/lib/compiler.h
@@ -1,13 +1,19 @@
#pragma once
-#define RENDER(X) extern "C" void __uce_render(X)
-#define COMPONENT(X) extern "C" void __uce_component(X)
-#define ONCE(X) extern "C" void __uce_once(X)
-#define INIT(X) extern "C" void __uce_init(X)
-#define WS(X) extern "C" void __uce_websocket(X)
-#define CLI(X) extern "C" void __uce_cli(X)
-#define SERVE_HTTP(X) extern "C" void __uce_serve_http(X)
-#define EXPORT extern "C"
+#if defined(__UCE_WASM_UNIT__)
+#define UCE_UNIT_EXPORT __attribute__((visibility("default")))
+#else
+#define UCE_UNIT_EXPORT
+#endif
+
+#define RENDER(X) extern "C" UCE_UNIT_EXPORT void __uce_render(X)
+#define COMPONENT(X) extern "C" UCE_UNIT_EXPORT void __uce_component(X)
+#define ONCE(X) extern "C" UCE_UNIT_EXPORT void __uce_once(X)
+#define INIT(X) extern "C" UCE_UNIT_EXPORT void __uce_init(X)
+#define WS(X) extern "C" UCE_UNIT_EXPORT void __uce_websocket(X)
+#define CLI(X) extern "C" UCE_UNIT_EXPORT void __uce_cli(X)
+#define SERVE_HTTP(X) extern "C" UCE_UNIT_EXPORT void __uce_serve_http(X)
+#define EXPORT extern "C" UCE_UNIT_EXPORT
String preprocess_shared_unit(Request* context, SharedUnit* su);
String compiler_generated_cpp_path(Request* context, String source_file);