feat(cli): add http.apps field for mounting multiple HTTP apps at path prefixes

Add support for the http.apps config field in langgraph.json that lets
users mount multiple HTTP apps at different path prefixes alongside the
built-in LangGraph API.

Config format:
  {
    "http": {
      "app": "./custom_routes.py:app",
      "apps": {
        "/dashboard": "./dashboard.py:app",
        "/ext": "./extension.js:app",
        "/conduit": "some-npm-package:app"
      }
    }
  }

Changes:
- Add apps: dict[str, str] | None to HttpConfig TypedDict in schemas.py
- Add validation for http.apps entries (prefix must start with /, values
  must be module:attribute format)
- Add _update_http_apps_paths() for Docker container path rewriting
- Add _is_js_http_app() and _has_js_http_apps() helpers for JS detection
- Ensure Node.js is installed in Docker images when JS HTTP apps present
- Regenerate JSON schema files

Co-authored-by: Christian Bromann <christian-bromann@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-03-23 06:09:31 +00:00
co-authored by Christian Bromann
parent 65572a0e6d
commit 468c7546c8
4 changed files with 158 additions and 3 deletions
+109 -1
View File
@@ -287,6 +287,18 @@ def validate_config(config: Config) -> Config:
f"Invalid http.app format: '{http_conf['app']}'. "
"Must be in format './path/to/file.py:attribute_name'"
)
if "apps" in http_conf and http_conf["apps"]:
for prefix, app_path in http_conf["apps"].items():
if not prefix.startswith("/"):
raise ValueError(
f"Invalid http.apps prefix: '{prefix}'. Must start with '/'"
)
if ":" not in app_path:
raise ValueError(
f"Invalid http.apps format for prefix '{prefix}': '{app_path}'. "
"Must be in format './path/to/file:attribute_name' "
"or 'package:attribute_name'"
)
if keep_pkg_tools := config.get("keep_pkg_tools"):
if isinstance(keep_pkg_tools, list):
for tool in keep_pkg_tools:
@@ -807,6 +819,94 @@ def _update_http_app_path(
http_config["app"] = f"{module_str}:{attr_str}"
_JS_EXTENSIONS = frozenset({".js", ".ts", ".mts", ".mjs", ".cts", ".cjs"})
def _is_js_http_app(app_path: str) -> bool:
"""Check if an http.apps entry is a JavaScript/TypeScript app or npm package."""
module_str = app_path.split(":")[0]
ext = os.path.splitext(module_str)[1]
if ext:
return ext in _JS_EXTENSIONS
# No extension and not a file path → npm package reference (JS)
if not module_str.startswith((".", "/")):
return True
return False
def _has_js_http_apps(config: Config) -> bool:
"""Check if any http.apps entries are JavaScript apps."""
http_config = config.get("http")
if not http_config:
return False
apps = http_config.get("apps")
if not apps:
return False
return any(_is_js_http_app(app_path) for app_path in apps.values())
def _update_http_apps_paths(
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
) -> None:
"""Update HTTP apps paths to point to the correct location in the Docker container.
Similar to _update_http_app_path, but handles the `http.apps` dictionary
which maps path prefixes to app module paths. Python app paths are rewritten
to container paths; npm package references are left unchanged.
"""
if not (http_config := config.get("http")) or not (apps := http_config.get("apps")):
return
for prefix, app_str in apps.items():
module_str, _, attr_str = app_str.partition(":")
if not module_str or not attr_str:
message = (
'Import string "{import_str}" must be in format "<module>:<attribute>".'
)
raise ValueError(message.format(import_str=app_str))
# Skip npm package references (bare module names without path separators)
if (
not module_str.startswith((".", "/"))
and not os.path.splitext(module_str)[1]
):
continue
if "/" in module_str or "\\" in module_str:
resolved = (config_path.parent / module_str).resolve()
if not resolved.exists():
raise FileNotFoundError(
f"Could not find HTTP app module for prefix '{prefix}': {resolved}"
)
elif not resolved.is_file():
raise IsADirectoryError(
f"HTTP app module must be a file for prefix '{prefix}': {resolved}"
)
else:
for path in local_deps.real_pkgs:
if resolved.is_relative_to(path):
container_path = (
pathlib.Path("/deps")
/ path.name
/ resolved.relative_to(path)
)
module_str = container_path.as_posix()
break
else:
for faux_pkg, (_, destpath) in local_deps.faux_pkgs.items():
if resolved.is_relative_to(faux_pkg):
container_subpath = resolved.relative_to(faux_pkg)
module_str = f"{destpath}/{container_subpath.as_posix()}"
break
else:
raise ValueError(
f"HTTP app module '{app_str}' for prefix '{prefix}' "
"not found in 'dependencies' list. "
"Add its containing package to 'dependencies' list."
)
apps[prefix] = f"{module_str}:{attr_str}"
def _get_node_pm_install_cmd(config_path: pathlib.Path, config: Config) -> str:
def test_file(file_name):
full_path = config_path.parent / file_name
@@ -957,6 +1057,8 @@ def python_config_to_docker(
_update_checkpointer_path(config_path, config, local_deps)
# Rewrite HTTP app path, so it points to the correct location in the Docker container
_update_http_app_path(config_path, config, local_deps)
# Rewrite HTTP apps paths, so they point to the correct location in the Docker container
_update_http_apps_paths(config_path, config, local_deps)
pip_pkgs_str = (
f"RUN {local_reqs_pip_install} {' '.join(pypi_deps)}" if pypi_deps else ""
@@ -1017,9 +1119,11 @@ ADD {relpath} /deps/{name}
for fullpath, (relpath, name) in local_deps.real_pkgs.items()
)
has_js_apps = _has_js_http_apps(config)
install_node_str: str = (
"RUN /storage/install-node.sh"
if (config.get("ui") or config.get("node_version")) and local_deps.working_dir
if ((config.get("ui") or config.get("node_version")) and local_deps.working_dir)
or has_js_apps
else ""
)
@@ -1078,6 +1182,10 @@ ADD {relpath} /deps/{name}
"# -- End of JS dependencies install --",
]
)
elif has_js_apps:
js_inst_str = (
f"ENV NODE_VERSION={config.get('node_version') or DEFAULT_NODE_VERSION}"
)
image_str = docker_tag(config, base_image, api_version)
# Prepare docker file contents
+19
View File
@@ -446,6 +446,25 @@ class HttpConfig(TypedDict, total=False):
Format: "path/to/module.py:app_var"
If provided, it can override or extend the default routes.
"""
apps: dict[str, str] | None
"""Optional. Record mapping path prefix to app module path.
Each key is a URL path prefix (e.g., "/dashboard"), and each value is an
import path in "file:export" format (e.g., "./dashboard.py:app").
Python apps (.py) are mounted in-process as FastAPI/Starlette sub-applications.
JS apps (.js, .ts, .mts, .mjs) are spawned as Node.js subprocess servers
with a reverse proxy forwarding requests from the main server.
npm package references (paths not starting with . or /) are resolved as
npm imports in the JS subprocess.
Example:
{
"/dashboard": "./dashboard.py:app",
"/ext": "./extension.js:app",
"/conduit": "some-npm-package:app"
}
"""
disable_assistants: bool
"""Optional. If `True`, /assistants routes are removed from the server.
+15 -1
View File
@@ -687,7 +687,7 @@
},
"EncryptionConfig": {
"title": "EncryptionConfig",
"description": "Configuration for custom at-rest encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database,\nincluding metadata fields and checkpoint blobs.",
"description": "Configuration for custom at-rest encryption logic.\n\n Allows you to implement custom encryption for sensitive data stored in the database,\n including metadata fields and checkpoint blobs.",
"type": "object",
"properties": {
"path": {
@@ -728,6 +728,20 @@
"type": "string",
"description": "Optional. Import path to a custom Starlette/FastAPI application to mount.\n"
},
"apps": {
"anyOf": [
{
"type": "object",
"additionalProperties": {
"type": "string"
}
},
{
"type": "null"
}
],
"description": "Optional. Record mapping path prefix to app module path.\n\nEach key is a URL path prefix (e.g., \"/dashboard\"), and each value is an\n\nPython apps (.py) are mounted in-process as FastAPI/Starlette sub-applications.\nJS apps (.js, .ts, .mts, .mjs) are spawned as Node.js subprocess servers\nwith a reverse proxy forwarding requests from the main server.\nnpm package references (paths not starting with . or /) are resolved as\nnpm imports in the JS subprocess.\n"
},
"configurable_headers": {
"anyOf": [
{
+15 -1
View File
@@ -687,7 +687,7 @@
},
"EncryptionConfig": {
"title": "EncryptionConfig",
"description": "Configuration for custom at-rest encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database,\nincluding metadata fields and checkpoint blobs.",
"description": "Configuration for custom at-rest encryption logic.\n\n Allows you to implement custom encryption for sensitive data stored in the database,\n including metadata fields and checkpoint blobs.",
"type": "object",
"properties": {
"path": {
@@ -728,6 +728,20 @@
"type": "string",
"description": "Optional. Import path to a custom Starlette/FastAPI application to mount.\n"
},
"apps": {
"anyOf": [
{
"type": "object",
"additionalProperties": {
"type": "string"
}
},
{
"type": "null"
}
],
"description": "Optional. Record mapping path prefix to app module path.\n\nEach key is a URL path prefix (e.g., \"/dashboard\"), and each value is an\n\nPython apps (.py) are mounted in-process as FastAPI/Starlette sub-applications.\nJS apps (.js, .ts, .mts, .mjs) are spawned as Node.js subprocess servers\nwith a reverse proxy forwarding requests from the main server.\nnpm package references (paths not starting with . or /) are resolved as\nnpm imports in the JS subprocess.\n"
},
"configurable_headers": {
"anyOf": [
{