Skip to content

Commit cefabf1

Browse files
committed
Add input validation, Node.js support, and Docker test environment
- Add input validation for Map parameters (zoom, pitch, bearing, center, dimensions) - Add Node.js runtime support alongside Bun for maplibre-gl-native compatibility - Update Dockerfile.test for integration tests with Xvfb headless rendering - Add 15 validation unit tests (TestValidation class) - Fix vendor binary detection with ldd system library checks - Update URLs and version to 0.1.0-alpha - Improve error messages with actionable guidance
1 parent d15fec4 commit cefabf1

11 files changed

Lines changed: 781 additions & 190 deletions

File tree

Dockerfile.test

Lines changed: 35 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,59 @@
11
# Dockerfile for running mlnative tests with proper library versions
2+
# Provides libjpeg8 and libicu74 needed by maplibre-gl-native
3+
# Uses Node.js because maplibre-gl-native is a V8 native addon
24
FROM ubuntu:24.04
35

4-
# Install dependencies
5-
RUN apt-get update && apt-get install -y \
6+
# Install minimal dependencies + xvfb for headless rendering
7+
RUN apt-get update && apt-get install -y --no-install-recommends \
68
curl \
7-
build-essential \
9+
ca-certificates \
810
libjpeg8 \
911
libicu74 \
1012
libgl1 \
1113
libglx0 \
1214
libopengl0 \
15+
libcurl4 \
16+
libuv1 \
17+
libx11-6 \
18+
libxext6 \
19+
libwebp7 \
20+
libpng16-16t64 \
1321
python3.12 \
1422
python3.12-venv \
15-
python3-pip \
23+
unzip \
24+
xvfb \
1625
&& rm -rf /var/lib/apt/lists/*
1726

18-
# Install mise (tool manager)
19-
RUN curl https://mise.run | sh
20-
ENV PATH="/root/.local/bin:$PATH"
21-
22-
# Trust mise config and install tools
23-
RUN mise trust
24-
RUN mise install
25-
2627
# Set working directory
2728
WORKDIR /app
2829

29-
# Copy config files first (for tool installation)
30-
COPY mise.toml ./
30+
# Install mise and tools (includes node for native module compatibility)
31+
RUN curl https://mise.run | sh
32+
ENV PATH="/root/.local/bin:$PATH"
3133

32-
# Trust and install tools
33-
RUN mise trust
34-
RUN mise install
34+
# Create mise config with Node 22 (ABI 127 has prebuilt maplibre binaries)
35+
RUN echo '[tools]\nuv = "latest"\njust = "latest"\nnode = "22"' > /app/mise.toml
36+
RUN mise trust && mise install
3537

36-
# Copy remaining project files
38+
# Copy project files
3739
COPY pyproject.toml Justfile README.md ./
3840
COPY mlnative/ ./mlnative/
3941
COPY examples/ ./examples/
4042
COPY tests/ ./tests/
4143
COPY scripts/ ./scripts/
4244

43-
# Setup project using mise exec
44-
RUN mise exec -- just setup
45-
46-
# Build vendor binaries
47-
RUN mise exec -- just build-vendor
48-
49-
# Install native module
50-
RUN cd mlnative/_vendor/linux-x64 && \
51-
mise exec -- bun install @maplibre/maplibre-gl-native && \
52-
mise exec -- bun pm untrusted
53-
54-
# Run tests
55-
CMD ["mise", "exec", "--", "just", "test"]
45+
# Setup Python environment
46+
RUN mise exec -- uv venv && mise exec -- uv pip install -e ".[dev,web]"
47+
48+
# Install native module using npm with explicit postinstall
49+
RUN PLATFORM=$(uname -m | sed 's/x86_64/x64/' | sed 's/aarch64/arm64/') && \
50+
VENDOR_DIR="mlnative/_vendor/linux-${PLATFORM}" && \
51+
rm -rf "${VENDOR_DIR}/node_modules" "${VENDOR_DIR}/package-lock.json" && \
52+
mkdir -p "${VENDOR_DIR}" && \
53+
cd "${VENDOR_DIR}" && \
54+
echo '{"dependencies": {"@maplibre/maplibre-gl-native": "^6.3.0"}}' > package.json && \
55+
mise exec -- npm cache clean --force && \
56+
mise exec -- npm install --prefer-online
57+
58+
# Run tests with Xvfb virtual display
59+
CMD ["sh", "-c", "Xvfb :99 -screen 0 1024x768x24 & export DISPLAY=:99 && sleep 1 && mise exec -- just test"]

Justfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ test-filter PATTERN:
2828

2929
# Run only unit tests (skip integration tests that need vendor binaries)
3030
test-unit:
31-
uv run python -m pytest tests/ -v -k "not render" --tb=short
31+
uv run python -m pytest tests/ -v -k "Validation" --tb=short
3232

3333
# Run linting
3434
lint:
@@ -111,8 +111,8 @@ stats:
111111

112112
# Run tests in container (for systems with incompatible libraries)
113113
test-docker:
114-
podman build -f Dockerfile.test -t mlnative-test .
115-
podman run --rm mlnative-test
114+
docker build -f Dockerfile.test -t mlnative-test .
115+
docker run --rm mlnative-test
116116

117117
# Install all tools via mise (local dev)
118118
install-tools:

mlnative/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@
77
from .exceptions import MlnativeError
88
from .map import Map
99

10-
__version__ = "0.1.0"
10+
__version__ = "0.1.0-alpha"
1111
__all__ = ["Map", "MlnativeError"]

mlnative/_bridge.py

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
"""
2-
Bridge to Bun/JavaScript renderer.
2+
Bridge to JavaScript renderer.
33
4-
Handles subprocess communication with the bundled JS renderer.
4+
Handles subprocess communication with the JS renderer.
5+
Uses Node.js when available (required for native modules), falls back to Bun.
56
"""
67

78
import json
89
import os
10+
import shutil
911
import subprocess
1012
import sys
1113
from collections.abc import Callable
1214
from pathlib import Path
1315
from typing import Any
1416

15-
import pybun
16-
1717
from .exceptions import MlnativeError
1818

1919

@@ -58,9 +58,55 @@ def get_vendor_dir() -> Path:
5858
f"Supported platforms: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-x64"
5959
)
6060

61+
# Check that node_modules is actually installed
62+
node_modules = vendor_dir / "node_modules" / "@maplibre" / "maplibre-gl-native"
63+
if not node_modules.exists():
64+
raise MlnativeError(
65+
f"Native binaries not installed for {vendor_name}.\n\n"
66+
f"To fix, run:\n"
67+
f" cd {vendor_dir} && npm install\n\n"
68+
f"Or if using pip, try reinstalling:\n"
69+
f" pip install --force-reinstall mlnative"
70+
)
71+
6172
return vendor_dir
6273

6374

75+
def _get_js_runtime() -> str:
76+
"""
77+
Get path to JavaScript runtime.
78+
79+
Prefers Node.js (required for native modules like maplibre-gl-native),
80+
falls back to bundled Bun from pybun package.
81+
"""
82+
# First, try to find node in PATH (required for native modules)
83+
node_path = shutil.which("node")
84+
if node_path:
85+
return node_path
86+
87+
# Fall back to bundled bun from pybun
88+
# Note: Bun may not work with all native modules due to V8/JSC differences
89+
try:
90+
import pybun
91+
92+
pybun_file = pybun.__file__
93+
if pybun_file:
94+
bun_path = Path(pybun_file).parent / "bun"
95+
if bun_path.exists():
96+
return str(bun_path)
97+
except ImportError:
98+
pass
99+
100+
raise MlnativeError(
101+
"No JavaScript runtime found.\n\n"
102+
"Install Node.js (recommended for native module compatibility):\n"
103+
" - macOS: brew install node\n"
104+
" - Linux: apt install nodejs or use nvm\n"
105+
" - Windows: https://nodejs.org/\n\n"
106+
"Or install pybun: pip install pybun"
107+
)
108+
109+
64110
def _validate_png_output(stdout: bytes) -> bytes:
65111
"""Validate that output is valid PNG bytes."""
66112
if not stdout.startswith(b"\x89PNG"):
@@ -73,16 +119,16 @@ def _validate_png_output(stdout: bytes) -> bytes:
73119
return stdout
74120

75121

76-
def _run_bun_process(
77-
bun_path: str, renderer_js: Path, config: dict[str, Any], vendor_dir: Path
122+
def _run_js_process(
123+
runtime_path: str, renderer_js: Path, config: dict[str, Any], vendor_dir: Path
78124
) -> bytes:
79-
"""Execute Bun subprocess and return output."""
125+
"""Execute JavaScript subprocess and return output."""
80126
env = os.environ.copy()
81127
env["MLNATIVE_VENDOR_DIR"] = str(vendor_dir)
82128

83129
try:
84130
result = subprocess.run(
85-
[bun_path, str(renderer_js)],
131+
[runtime_path, str(renderer_js)],
86132
input=json.dumps(config).encode("utf-8"),
87133
capture_output=True,
88134
env=env,
@@ -92,11 +138,11 @@ def _run_bun_process(
92138
raise MlnativeError("Render timeout (60s exceeded)") from None
93139
except subprocess.CalledProcessError as e:
94140
stderr = e.stderr.decode("utf-8", errors="replace") if e.stderr else "Unknown error"
95-
raise MlnativeError(f"Bun process failed:\n{stderr}") from e
141+
raise MlnativeError(f"JS process failed:\n{stderr}") from e
96142

97143
if result.returncode != 0:
98144
stderr = result.stderr.decode("utf-8", errors="replace")
99-
raise MlnativeError(f"Bun render failed:\n{stderr}")
145+
raise MlnativeError(f"Render failed:\n{stderr}")
100146

101147
return result.stdout
102148

@@ -105,7 +151,7 @@ def render_with_bun(
105151
config: dict[str, Any], request_handler: Callable[[Any], bytes] | None = None
106152
) -> bytes:
107153
"""
108-
Render map using Bun subprocess.
154+
Render map using JavaScript subprocess.
109155
110156
Args:
111157
config: Map configuration dict
@@ -123,11 +169,10 @@ def render_with_bun(
123169
if not renderer_js.exists():
124170
raise MlnativeError(f"Renderer script not found: {renderer_js}")
125171

126-
# Get bun path from pybun package (bundled binary)
127-
bun_path = str(Path(pybun.__file__).parent / "bun")
172+
runtime_path = _get_js_runtime()
128173

129174
# Flag for custom handler (JS side uses temp file protocol)
130175
config["_hasCustomHandler"] = request_handler is not None
131176

132-
stdout = _run_bun_process(bun_path, renderer_js, config, vendor_dir)
177+
stdout = _run_js_process(runtime_path, renderer_js, config, vendor_dir)
133178
return _validate_png_output(stdout)

mlnative/_renderer.js

Lines changed: 52 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
#!/usr/bin/env bun
1+
#!/usr/bin/env node
22
/**
33
* MapLibre GL Native renderer for mlnative.
44
*
55
* Reads JSON config from stdin, renders map, outputs PNG bytes to stdout.
6+
* Works with both Node.js and Bun runtimes.
67
*/
78

89
const fs = require('fs');
@@ -69,47 +70,59 @@ const mapOptions = {
6970
ratio: config.pixelRatio || 1
7071
};
7172

72-
// Handle style
73-
let style = config.style;
74-
if (typeof style === 'string') {
75-
// URL - will be fetched by request handler
76-
// Nothing to do
77-
} else if (typeof style === 'object') {
78-
// Inline style object
79-
// Already set
80-
}
81-
8273
// Create and configure map
8374
const map = new mbgl.Map(mapOptions);
8475

85-
// Load style
86-
try {
87-
map.load(style);
88-
} catch (e) {
89-
console.error(`Failed to load style: ${e.message}`);
90-
process.exit(1);
91-
}
92-
93-
// Render options
94-
const renderOptions = {
95-
zoom: config.zoom,
96-
center: config.center,
97-
bearing: config.bearing || 0,
98-
pitch: config.pitch || 0,
99-
width: config.width,
100-
height: config.height
101-
};
102-
103-
// Render
104-
map.render(renderOptions, (err, buffer) => {
105-
if (err) {
106-
console.error(`Render error: ${err.message}`);
107-
map.release();
76+
// Function to load style and render
77+
async function loadStyleAndRender() {
78+
let style = config.style;
79+
80+
// If style is a URL, fetch it first
81+
if (typeof style === 'string' && (style.startsWith('http://') || style.startsWith('https://'))) {
82+
try {
83+
const response = await fetch(style);
84+
if (!response.ok) {
85+
throw new Error(`Failed to fetch style: HTTP ${response.status}`);
86+
}
87+
style = await response.json();
88+
} catch (e) {
89+
console.error(`Failed to fetch style from ${config.style}: ${e.message}`);
90+
process.exit(1);
91+
}
92+
}
93+
94+
// Load style
95+
try {
96+
map.load(style);
97+
} catch (e) {
98+
console.error(`Failed to load style: ${e.message}`);
10899
process.exit(1);
109100
}
110101

111-
// Output PNG bytes to stdout
112-
process.stdout.write(buffer);
113-
map.release();
114-
process.exit(0);
115-
});
102+
// Render options
103+
const renderOptions = {
104+
zoom: config.zoom,
105+
center: config.center,
106+
bearing: config.bearing || 0,
107+
pitch: config.pitch || 0,
108+
width: config.width,
109+
height: config.height
110+
};
111+
112+
// Render
113+
map.render(renderOptions, (err, buffer) => {
114+
if (err) {
115+
console.error(`Render error: ${err.message}`);
116+
map.release();
117+
process.exit(1);
118+
}
119+
120+
// Output PNG bytes to stdout
121+
process.stdout.write(buffer);
122+
map.release();
123+
process.exit(0);
124+
});
125+
}
126+
127+
// Run
128+
loadStyleAndRender();

0 commit comments

Comments
 (0)