mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-22 15:42:25 +02:00
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:
co-authored by
Christian Bromann
parent
65572a0e6d
commit
468c7546c8
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user