mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 12:59:18 +08:00
feat: add frontend environment reference generation
- Introduced `frontend-env.reference.json` and `frontend-env.reference.md` to document frontend environment variables. - Implemented `env-reference.mjs` script to extract and generate environment variable metadata from `web/env.ts`. - Added tests for environment reference generation in `env-reference.spec.ts`.
This commit is contained in:
parent
b7666af311
commit
1b49059231
6
.gitignore
vendored
6
.gitignore
vendored
@ -245,3 +245,9 @@ scripts/stress-test/reports/
|
||||
.qoder/*
|
||||
|
||||
.eslintcache
|
||||
|
||||
docker/.codex/
|
||||
docker/.claude/
|
||||
docker/defaults
|
||||
docker/openspec
|
||||
docker/AGENTS.md
|
||||
|
||||
431
api/configs/env_reference.py
Normal file
431
api/configs/env_reference.py
Normal file
@ -0,0 +1,431 @@
|
||||
"""Generate a backend env reference from the authoritative config model.
|
||||
|
||||
This module derives backend env input metadata from ``DifyConfig`` instead of
|
||||
grepping individual files. The exported reference intentionally captures only
|
||||
code-defined semantics and fallback defaults; it does not attempt to represent
|
||||
deployment defaults or runtime-effective values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from types import UnionType
|
||||
from typing import Any, TypedDict, get_args, get_origin
|
||||
|
||||
from pydantic import AliasChoices, BaseModel
|
||||
from pydantic.fields import FieldInfo
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
from .app_config import DifyConfig
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_API_ROOT = Path(__file__).resolve().parents[1]
|
||||
_DOCS_ROOT = _API_ROOT / "docs"
|
||||
_JSON_OUTPUT = _DOCS_ROOT / "backend-env.reference.json"
|
||||
_MARKDOWN_OUTPUT = _DOCS_ROOT / "backend-env.reference.md"
|
||||
_SENSITIVE_SUFFIXES = (
|
||||
"_PASSWORD",
|
||||
"_SECRET",
|
||||
"_TOKEN",
|
||||
"_API_KEY",
|
||||
"_ACCESS_KEY",
|
||||
"_SECRET_KEY",
|
||||
"_PRIVATE_KEY",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DESCRIPTION_REWRITES = {
|
||||
"Duration in minutes for which a account deletion token remains valid": (
|
||||
"Duration in minutes for which an account deletion token remains valid."
|
||||
),
|
||||
"whether to enable education identity": "Whether to enable education identity.",
|
||||
(
|
||||
"Granularity for async workflow scheduler, sometime, few users could block the queue "
|
||||
"due to some time-consuming tasks, to avoid this, workflow can be suspended if needed, "
|
||||
"to achievethis, a time-based checker is required, every granularity seconds, "
|
||||
"the checker will check the workflow queue and suspend the workflow"
|
||||
): (
|
||||
"Granularity for the async workflow scheduler. Some users could block the queue with "
|
||||
"time-consuming tasks, so workflows can be suspended when needed. A time-based checker "
|
||||
"runs every granularity seconds to inspect the queue and suspend workflows."
|
||||
),
|
||||
(
|
||||
"Base URL for file preview or download, used for frontend display and multi-model "
|
||||
"inputsUrl is signed and has expiration time."
|
||||
): (
|
||||
"Base URL for file preview or download, used for frontend display and multi-model "
|
||||
"inputs. The URL is signed and has an expiration time."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class BackendEnvVariableReference(TypedDict):
|
||||
name: str
|
||||
accepted_names: list[str]
|
||||
group: str
|
||||
type: str
|
||||
description: str
|
||||
code_default: Any | None
|
||||
required: bool
|
||||
applies_when: str | None
|
||||
|
||||
|
||||
class BackendEnvReference(TypedDict):
|
||||
schema_version: str
|
||||
artifact_policy: str
|
||||
authority: dict[str, str]
|
||||
resolution: dict[str, list[str]]
|
||||
variables: list[BackendEnvVariableReference]
|
||||
|
||||
|
||||
def _config_classes() -> list[type[BaseSettings]]:
|
||||
return [
|
||||
cls
|
||||
for cls in DifyConfig.__mro__[1:]
|
||||
if inspect.isclass(cls)
|
||||
and issubclass(cls, BaseSettings)
|
||||
and cls is not BaseSettings
|
||||
and cls.__module__.startswith("configs.")
|
||||
]
|
||||
|
||||
|
||||
def _owner_class_for_field(field_name: str) -> type[BaseSettings] | None:
|
||||
for cls in _config_classes():
|
||||
if field_name in getattr(cls, "__annotations__", {}):
|
||||
return cls
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_name(name: str) -> str:
|
||||
return re.sub(r"(?<!^)(?=[A-Z])", "-", name).replace("_", "-").lower()
|
||||
|
||||
|
||||
def _group_for_owner(owner: type[BaseSettings]) -> str:
|
||||
module_parts = owner.__module__.removeprefix("configs.").split(".")
|
||||
if module_parts[-1].endswith("_config"):
|
||||
module_parts = module_parts[:-1]
|
||||
return ".".join([*module_parts, _normalize_name(owner.__name__.removesuffix("Config"))])
|
||||
|
||||
|
||||
def _accepted_names(field_name: str, field_info: FieldInfo) -> list[str]:
|
||||
alias = field_info.validation_alias
|
||||
if isinstance(alias, AliasChoices):
|
||||
names = [str(choice) for choice in alias.choices]
|
||||
elif isinstance(alias, str):
|
||||
names = [alias]
|
||||
else:
|
||||
names = [field_name]
|
||||
|
||||
if field_name not in names:
|
||||
names.append(field_name)
|
||||
return names
|
||||
|
||||
|
||||
def _type_name(annotation: Any) -> str:
|
||||
origin = get_origin(annotation)
|
||||
if origin is None:
|
||||
if annotation in {str, Any}:
|
||||
return "string"
|
||||
if annotation is bool:
|
||||
return "boolean"
|
||||
if annotation is int:
|
||||
return "integer"
|
||||
if annotation is float:
|
||||
return "float"
|
||||
if annotation is type(None):
|
||||
return "null"
|
||||
if inspect.isclass(annotation):
|
||||
if issubclass(annotation, Enum):
|
||||
return "enum"
|
||||
if issubclass(annotation, str):
|
||||
return "string"
|
||||
if issubclass(annotation, bool):
|
||||
return "boolean"
|
||||
if issubclass(annotation, int):
|
||||
return "integer"
|
||||
if issubclass(annotation, float):
|
||||
return "float"
|
||||
return getattr(annotation, "__name__", str(annotation))
|
||||
|
||||
if origin is UnionType or str(origin).endswith("Union"):
|
||||
args = [arg for arg in get_args(annotation) if arg is not type(None)]
|
||||
rendered = " | ".join(_type_name(arg) for arg in args) if args else "null"
|
||||
if len(args) != len(get_args(annotation)):
|
||||
return f"{rendered} | null"
|
||||
return rendered
|
||||
|
||||
if str(origin).endswith("Literal"):
|
||||
values = ", ".join(repr(value) for value in get_args(annotation))
|
||||
return f"literal[{values}]"
|
||||
|
||||
if str(origin).endswith("Annotated"):
|
||||
args = get_args(annotation)
|
||||
return _type_name(args[0]) if args else "annotated"
|
||||
|
||||
if origin in {list, tuple, set}:
|
||||
args = get_args(annotation)
|
||||
item_type = _type_name(args[0]) if args else "any"
|
||||
return f"{origin.__name__}[{item_type}]"
|
||||
|
||||
return str(annotation)
|
||||
|
||||
|
||||
def _serialize_default(value: Any) -> Any | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, BaseModel):
|
||||
return value.model_dump(mode="json")
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
if isinstance(value, Path):
|
||||
return str(value)
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_serialize_default(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _serialize_default(item) for key, item in value.items()}
|
||||
return str(value)
|
||||
|
||||
|
||||
def _markdown_cell(value: Any | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
|
||||
text = str(value)
|
||||
normalized = " ".join(text.split())
|
||||
return normalized.replace("|", "\\|")
|
||||
|
||||
|
||||
def _markdown_code_cell(value: Any | None, *, empty: str = "") -> str:
|
||||
text = _markdown_cell(value)
|
||||
if not text:
|
||||
return empty
|
||||
return f"`{text.replace('`', '\\`')}`"
|
||||
|
||||
|
||||
def _render_code_default(value: Any | None) -> str:
|
||||
if value is None:
|
||||
return _markdown_code_cell(json.dumps("", ensure_ascii=False))
|
||||
|
||||
if isinstance(value, str):
|
||||
return _markdown_code_cell(json.dumps(" ".join(value.split()), ensure_ascii=False))
|
||||
|
||||
return _markdown_code_cell(json.dumps(value, ensure_ascii=False))
|
||||
|
||||
|
||||
def _normalize_description(description: str) -> str:
|
||||
normalized = " ".join(description.split())
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
rewritten = _DESCRIPTION_REWRITES.get(normalized, normalized)
|
||||
rewritten = re.sub(r"(?<=[.!?])(?=[A-Z])", " ", rewritten)
|
||||
rewritten = re.sub(r"(?<=\w),(?=[A-Za-z])", ", ", rewritten)
|
||||
rewritten = re.sub(r"(?<=:)(?=https?://)", " ", rewritten)
|
||||
rewritten = re.sub(r"(?<=\w)\((?=e\.g\.,)", " (", rewritten)
|
||||
return rewritten
|
||||
|
||||
|
||||
def _render_group_applicability_notes(variables: list[BackendEnvVariableReference]) -> list[str]:
|
||||
applies_when_groups: dict[str, list[str]] = defaultdict(list)
|
||||
for variable in variables:
|
||||
applies_when = variable["applies_when"]
|
||||
if applies_when:
|
||||
applies_when_groups[applies_when].append(variable["name"])
|
||||
|
||||
if not applies_when_groups:
|
||||
return []
|
||||
|
||||
if len(applies_when_groups) == 1 and len(next(iter(applies_when_groups.values()))) == len(variables):
|
||||
applies_when = next(iter(applies_when_groups))
|
||||
return [f"> Applies when: {_markdown_code_cell(applies_when)}", ""]
|
||||
|
||||
lines = ["Applies when:"]
|
||||
for applies_when, names in sorted(applies_when_groups.items()):
|
||||
joined_names = ", ".join(f"`{name}`" for name in sorted(names))
|
||||
lines.append(f"- {joined_names}: {_markdown_code_cell(applies_when)}")
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def _provider_applies_when(owner: type[BaseSettings], field_name: str) -> str | None:
|
||||
source_file = Path(inspect.getsourcefile(owner) or "")
|
||||
source_name = source_file.name
|
||||
|
||||
storage_map = {
|
||||
"amazon_s3_storage_config.py": "STORAGE_TYPE=s3",
|
||||
"aliyun_oss_storage_config.py": "STORAGE_TYPE=aliyun-oss",
|
||||
"azure_blob_storage_config.py": "STORAGE_TYPE=azure-blob",
|
||||
"baidu_obs_storage_config.py": "STORAGE_TYPE=baidu-obs",
|
||||
"clickzetta_volume_storage_config.py": "STORAGE_TYPE=clickzetta-volume",
|
||||
"google_cloud_storage_config.py": "STORAGE_TYPE=google-storage",
|
||||
"huawei_obs_storage_config.py": "STORAGE_TYPE=huawei-obs",
|
||||
"oci_storage_config.py": "STORAGE_TYPE=oci-storage",
|
||||
"opendal_storage_config.py": "STORAGE_TYPE=opendal",
|
||||
"supabase_storage_config.py": "STORAGE_TYPE=supabase",
|
||||
"tencent_cos_storage_config.py": "STORAGE_TYPE=tencent-cos",
|
||||
"volcengine_tos_storage_config.py": "STORAGE_TYPE=volcengine-tos",
|
||||
}
|
||||
if field_name == "STORAGE_LOCAL_PATH":
|
||||
return "STORAGE_TYPE=local"
|
||||
if source_name in storage_map:
|
||||
return storage_map[source_name]
|
||||
|
||||
vector_map = {
|
||||
"analyticdb_config.py": "VECTOR_STORE=analyticdb",
|
||||
"baidu_vector_config.py": "VECTOR_STORE=baidu_vector",
|
||||
"chroma_config.py": "VECTOR_STORE=chroma",
|
||||
"clickzetta_config.py": "VECTOR_STORE=clickzetta",
|
||||
"couchbase_config.py": "VECTOR_STORE=couchbase",
|
||||
"elasticsearch_config.py": "VECTOR_STORE=elasticsearch",
|
||||
"hologres_config.py": "VECTOR_STORE=hologres",
|
||||
"huawei_cloud_config.py": "VECTOR_STORE=huawei-cloud",
|
||||
"iris_config.py": "VECTOR_STORE=iris",
|
||||
"lindorm_config.py": "VECTOR_STORE=lindorm",
|
||||
"matrixone_config.py": "VECTOR_STORE=matrixone",
|
||||
"milvus_config.py": "VECTOR_STORE=milvus",
|
||||
"myscale_config.py": "VECTOR_STORE=myscale",
|
||||
"oceanbase_config.py": "VECTOR_STORE=oceanbase",
|
||||
"opengauss_config.py": "VECTOR_STORE=opengauss",
|
||||
"opensearch_config.py": "VECTOR_STORE=opensearch",
|
||||
"oracle_config.py": "VECTOR_STORE=oracle",
|
||||
"pgvector_config.py": "VECTOR_STORE=pgvector",
|
||||
"pgvectors_config.py": "VECTOR_STORE=pgvectors",
|
||||
"qdrant_config.py": "VECTOR_STORE=qdrant",
|
||||
"relyt_config.py": "VECTOR_STORE=relyt",
|
||||
"tablestore_config.py": "VECTOR_STORE=tablestore",
|
||||
"tencent_vector_config.py": "VECTOR_STORE=tencent",
|
||||
"tidb_on_qdrant_config.py": "VECTOR_STORE=tidb_on_qdrant",
|
||||
"tidb_vector_config.py": "VECTOR_STORE=tidb_vector",
|
||||
"upstash_config.py": "VECTOR_STORE=upstash",
|
||||
"vastbase_vector_config.py": "VECTOR_STORE=vastbase",
|
||||
"vikingdb_config.py": "VECTOR_STORE=vikingdb",
|
||||
"weaviate_config.py": "VECTOR_STORE=weaviate",
|
||||
"alibabacloud_mysql_config.py": "VECTOR_STORE=alibabacloud-mysql",
|
||||
}
|
||||
applies_when = vector_map.get(source_name)
|
||||
if (
|
||||
applies_when
|
||||
and source_name == "elasticsearch_config.py"
|
||||
and ("CLOUD" in field_name or field_name in {"ELASTICSEARCH_API_KEY", "ELASTICSEARCH_CA_CERTS"})
|
||||
):
|
||||
return f"{applies_when}; ELASTICSEARCH_USE_CLOUD=true"
|
||||
return applies_when
|
||||
|
||||
|
||||
def build_backend_env_reference() -> BackendEnvReference:
|
||||
variables: list[BackendEnvVariableReference] = []
|
||||
|
||||
for field_name, field_info in sorted(DifyConfig.model_fields.items()):
|
||||
if not field_name.isupper():
|
||||
continue
|
||||
|
||||
owner = _owner_class_for_field(field_name)
|
||||
if owner is None:
|
||||
continue
|
||||
|
||||
variables.append(
|
||||
{
|
||||
"name": field_name,
|
||||
"accepted_names": _accepted_names(field_name, field_info),
|
||||
"group": _group_for_owner(owner),
|
||||
"type": _type_name(field_info.annotation),
|
||||
"description": field_info.description or "",
|
||||
"code_default": None if field_info.is_required() else _serialize_default(field_info.default),
|
||||
"required": field_info.is_required(),
|
||||
"applies_when": _provider_applies_when(owner, field_name),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"schema_version": "1",
|
||||
"artifact_policy": "committed-generated-artifact",
|
||||
"authority": {
|
||||
"kind": "backend-code-defaults",
|
||||
"source_root": "api/configs",
|
||||
"model": "configs.app_config.DifyConfig",
|
||||
},
|
||||
"resolution": {
|
||||
"precedence": [
|
||||
"init_settings",
|
||||
"process_env",
|
||||
"remote_settings",
|
||||
"dotenv",
|
||||
"file_secrets",
|
||||
"toml",
|
||||
"code_default",
|
||||
]
|
||||
},
|
||||
"variables": variables,
|
||||
}
|
||||
|
||||
|
||||
def render_backend_env_reference_markdown(reference: BackendEnvReference) -> str:
|
||||
grouped: dict[str, list[BackendEnvVariableReference]] = defaultdict(list)
|
||||
for variable in reference["variables"]:
|
||||
grouped[variable["group"]].append(variable)
|
||||
|
||||
lines = [
|
||||
"# Backend Env Reference",
|
||||
"",
|
||||
"> Generated from `api/configs/**/*.py`. Do not edit manually.",
|
||||
"",
|
||||
"This reference documents backend env input semantics and code defaults only.",
|
||||
"Deployment defaults, `.env.example`, and runtime-effective values are intentionally excluded.",
|
||||
"",
|
||||
"## Value Resolution Order",
|
||||
"",
|
||||
"```text",
|
||||
" > ".join(reference["resolution"]["precedence"]),
|
||||
"```",
|
||||
"",
|
||||
"Code defaults are fallback values only. Runtime process environment, remote settings, and dotenv values can override them.",
|
||||
"",
|
||||
]
|
||||
|
||||
for group in sorted(grouped):
|
||||
lines.extend([f"## `{group}`", ""])
|
||||
lines.extend(_render_group_applicability_notes(grouped[group]))
|
||||
lines.append("| Name | Type | Default | Accepted Env Names | Description |")
|
||||
lines.append("| --- | --- | --- | --- | --- |")
|
||||
|
||||
for variable in grouped[group]:
|
||||
code_default = _render_code_default(variable["code_default"])
|
||||
aliases = _markdown_code_cell(", ".join(variable["accepted_names"]))
|
||||
description = _markdown_cell(_normalize_description(variable["description"]))
|
||||
variable_type = _markdown_code_cell(variable["type"])
|
||||
lines.append(
|
||||
f"| `{variable['name']}` | {variable_type} | {code_default} | {aliases} | {description} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write_backend_env_reference(
|
||||
json_output: Path = _JSON_OUTPUT,
|
||||
markdown_output: Path = _MARKDOWN_OUTPUT,
|
||||
) -> tuple[Path, Path]:
|
||||
reference = build_backend_env_reference()
|
||||
json_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
markdown_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_output.write_text(json.dumps(reference, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
markdown_output.write_text(render_backend_env_reference_markdown(reference) + "\n", encoding="utf-8")
|
||||
return json_output, markdown_output
|
||||
|
||||
|
||||
def main() -> None:
|
||||
json_output, markdown_output = write_backend_env_reference()
|
||||
logger.info("Wrote %s", json_output.relative_to(_REPO_ROOT))
|
||||
logger.info("Wrote %s", markdown_output.relative_to(_REPO_ROOT))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
8307
api/docs/backend-env.reference.json
Normal file
8307
api/docs/backend-env.reference.json
Normal file
File diff suppressed because it is too large
Load Diff
1364
api/docs/backend-env.reference.md
Normal file
1364
api/docs/backend-env.reference.md
Normal file
File diff suppressed because it is too large
Load Diff
251
api/tests/unit_tests/configs/test_env_reference.py
Normal file
251
api/tests/unit_tests/configs/test_env_reference.py
Normal file
@ -0,0 +1,251 @@
|
||||
import json
|
||||
|
||||
from configs.env_reference import (
|
||||
build_backend_env_reference,
|
||||
render_backend_env_reference_markdown,
|
||||
)
|
||||
|
||||
|
||||
def test_backend_env_reference_uses_backend_authority() -> None:
|
||||
reference = build_backend_env_reference()
|
||||
|
||||
assert reference["authority"]["source_root"] == "api/configs"
|
||||
assert reference["authority"]["model"] == "configs.app_config.DifyConfig"
|
||||
assert reference["resolution"]["precedence"][-1] == "code_default"
|
||||
|
||||
|
||||
def test_backend_env_reference_includes_aliases_and_defaults() -> None:
|
||||
reference = build_backend_env_reference()
|
||||
variables = {variable["name"]: variable for variable in reference["variables"]}
|
||||
|
||||
files_url = variables["FILES_URL"]
|
||||
redis_host = variables["REDIS_HOST"]
|
||||
|
||||
assert "CONSOLE_API_URL" in files_url["accepted_names"]
|
||||
assert redis_host["code_default"] == "localhost"
|
||||
assert redis_host["group"] == "middleware.cache.redis"
|
||||
assert "source_location" not in redis_host
|
||||
assert "sensitive" not in redis_host
|
||||
|
||||
|
||||
def test_backend_env_reference_excludes_computed_and_nested_fields() -> None:
|
||||
reference = build_backend_env_reference()
|
||||
names = {variable["name"] for variable in reference["variables"]}
|
||||
|
||||
assert "SQLALCHEMY_DATABASE_URI" not in names
|
||||
assert "normalized_pubsub_redis_url" not in names
|
||||
assert "project" not in names
|
||||
|
||||
|
||||
def test_backend_env_reference_marks_provider_applicability() -> None:
|
||||
reference = build_backend_env_reference()
|
||||
variables = {variable["name"]: variable for variable in reference["variables"]}
|
||||
|
||||
assert variables["S3_ACCESS_KEY"]["applies_when"] == "STORAGE_TYPE=s3"
|
||||
assert variables["STORAGE_LOCAL_PATH"]["applies_when"] == "STORAGE_TYPE=local"
|
||||
|
||||
|
||||
def test_backend_env_reference_markdown_explains_code_default_scope() -> None:
|
||||
reference = build_backend_env_reference()
|
||||
markdown = render_backend_env_reference_markdown(reference)
|
||||
|
||||
assert "Deployment defaults, `.env.example`, and runtime-effective values are intentionally excluded." in markdown
|
||||
assert "Code defaults are fallback values only." in markdown
|
||||
assert "`REDIS_HOST`" in markdown
|
||||
assert "| Name | Type | Default | Accepted Env Names | Description |" in markdown
|
||||
assert "Code Default" not in markdown
|
||||
assert "Required |" not in markdown
|
||||
assert "Applies When |" not in markdown
|
||||
assert "Source |" not in markdown
|
||||
|
||||
|
||||
def test_backend_env_reference_markdown_normalizes_multiline_cells() -> None:
|
||||
markdown = render_backend_env_reference_markdown(
|
||||
{
|
||||
"schema_version": "1",
|
||||
"artifact_policy": "committed-generated-artifact",
|
||||
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
|
||||
"resolution": {"precedence": ["process_env", "code_default"]},
|
||||
"variables": [
|
||||
{
|
||||
"name": "EXAMPLE_ENV",
|
||||
"accepted_names": ["EXAMPLE_ENV", "EXAMPLE_ALIAS"],
|
||||
"group": "test.group",
|
||||
"type": "string | null",
|
||||
"description": "line one\nline two | extra",
|
||||
"code_default": "value\nwith newline",
|
||||
"applies_when": "MODE=demo\nENABLED=true",
|
||||
"required": False,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert "line one line two \\| extra" in markdown
|
||||
assert "> Applies when: `MODE=demo ENABLED=true`" in markdown
|
||||
assert "`string \\| null`" in markdown
|
||||
assert '`"value with newline"`' in markdown
|
||||
assert "\nline two" not in markdown
|
||||
|
||||
|
||||
def test_backend_env_reference_markdown_groups_partial_applicability_notes() -> None:
|
||||
markdown = render_backend_env_reference_markdown(
|
||||
{
|
||||
"schema_version": "1",
|
||||
"artifact_policy": "committed-generated-artifact",
|
||||
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
|
||||
"resolution": {"precedence": ["process_env", "code_default"]},
|
||||
"variables": [
|
||||
{
|
||||
"name": "S3_ACCESS_KEY",
|
||||
"accepted_names": ["S3_ACCESS_KEY"],
|
||||
"group": "storage.s3",
|
||||
"type": "string",
|
||||
"description": "Access key",
|
||||
"code_default": None,
|
||||
"required": False,
|
||||
"applies_when": "STORAGE_TYPE=s3",
|
||||
},
|
||||
{
|
||||
"name": "S3_SECRET_KEY",
|
||||
"accepted_names": ["S3_SECRET_KEY"],
|
||||
"group": "storage.s3",
|
||||
"type": "string",
|
||||
"description": "Secret key",
|
||||
"code_default": None,
|
||||
"required": False,
|
||||
"applies_when": "STORAGE_TYPE=s3",
|
||||
},
|
||||
{
|
||||
"name": "STORAGE_ENDPOINT",
|
||||
"accepted_names": ["STORAGE_ENDPOINT"],
|
||||
"group": "storage.s3",
|
||||
"type": "string | null",
|
||||
"description": "Endpoint override",
|
||||
"code_default": None,
|
||||
"required": False,
|
||||
"applies_when": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert "Applies when:" in markdown
|
||||
assert "- `S3_ACCESS_KEY`, `S3_SECRET_KEY`: `STORAGE_TYPE=s3`" in markdown
|
||||
assert "Applies When |" not in markdown
|
||||
|
||||
|
||||
def test_backend_env_reference_markdown_normalizes_awkward_descriptions() -> None:
|
||||
markdown = render_backend_env_reference_markdown(
|
||||
{
|
||||
"schema_version": "1",
|
||||
"artifact_policy": "committed-generated-artifact",
|
||||
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
|
||||
"resolution": {"precedence": ["process_env", "code_default"]},
|
||||
"variables": [
|
||||
{
|
||||
"name": "ENTERPRISE_ENABLED",
|
||||
"accepted_names": ["ENTERPRISE_ENABLED"],
|
||||
"group": "enterprise.feature",
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Enable or disable enterprise-level features.Before using, please contact "
|
||||
"business@dify.ai by email to inquire about licensing matters."
|
||||
),
|
||||
"code_default": False,
|
||||
"required": False,
|
||||
"applies_when": None,
|
||||
},
|
||||
{
|
||||
"name": "FILES_URL",
|
||||
"accepted_names": ["FILES_URL", "CONSOLE_API_URL"],
|
||||
"group": "feature.file-access",
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Base URL for file preview or download, used for frontend display and "
|
||||
"multi-model inputsUrl is signed and has expiration time."
|
||||
),
|
||||
"code_default": "",
|
||||
"required": False,
|
||||
"applies_when": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert "features. Before using, please contact business@dify.ai" in markdown
|
||||
assert "multi-model inputs. The URL is signed and has an expiration time." in markdown
|
||||
|
||||
|
||||
def test_backend_env_reference_markdown_renders_missing_defaults_explicitly() -> None:
|
||||
markdown = render_backend_env_reference_markdown(
|
||||
{
|
||||
"schema_version": "1",
|
||||
"artifact_policy": "committed-generated-artifact",
|
||||
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
|
||||
"resolution": {"precedence": ["process_env", "code_default"]},
|
||||
"variables": [
|
||||
{
|
||||
"name": "SENTRY_DSN",
|
||||
"accepted_names": ["SENTRY_DSN"],
|
||||
"group": "extra.sentry",
|
||||
"type": "string | null",
|
||||
"description": "Sentry DSN",
|
||||
"code_default": None,
|
||||
"required": False,
|
||||
"applies_when": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
row = '| `SENTRY_DSN` | `string \\| null` | `""` | `SENTRY_DSN` | Sentry DSN |'
|
||||
|
||||
assert row in markdown
|
||||
assert row.count(" | ") == 4
|
||||
|
||||
|
||||
def test_backend_env_reference_markdown_keeps_code_default_column_styling_consistent() -> None:
|
||||
markdown = render_backend_env_reference_markdown(
|
||||
{
|
||||
"schema_version": "1",
|
||||
"artifact_policy": "committed-generated-artifact",
|
||||
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
|
||||
"resolution": {"precedence": ["process_env", "code_default"]},
|
||||
"variables": [
|
||||
{
|
||||
"name": "EMPTY_DEFAULT",
|
||||
"accepted_names": ["EMPTY_DEFAULT"],
|
||||
"group": "test.group",
|
||||
"type": "string | null",
|
||||
"description": "Empty default placeholder",
|
||||
"code_default": None,
|
||||
"required": False,
|
||||
"applies_when": None,
|
||||
},
|
||||
{
|
||||
"name": "STRING_DEFAULT",
|
||||
"accepted_names": ["STRING_DEFAULT"],
|
||||
"group": "test.group",
|
||||
"type": "string",
|
||||
"description": "Concrete string default",
|
||||
"code_default": "value",
|
||||
"required": False,
|
||||
"applies_when": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert '| `EMPTY_DEFAULT` | `string \\| null` | `""` | `EMPTY_DEFAULT` | Empty default placeholder |' in markdown
|
||||
assert '| `STRING_DEFAULT` | `string` | `"value"` | `STRING_DEFAULT` | Concrete string default |' in markdown
|
||||
|
||||
|
||||
def test_backend_env_reference_is_json_serializable() -> None:
|
||||
reference = build_backend_env_reference()
|
||||
rendered = json.dumps(reference)
|
||||
|
||||
assert '"schema_version": "1"' in rendered
|
||||
assert '"resolution"' in rendered
|
||||
assert '"source_location"' not in rendered
|
||||
assert '"sensitive"' not in rendered
|
||||
File diff suppressed because it is too large
Load Diff
655
web/docs/frontend-env.reference.json
Normal file
655
web/docs/frontend-env.reference.json
Normal file
@ -0,0 +1,655 @@
|
||||
{
|
||||
"schema_version": "1",
|
||||
"artifact_policy": "committed-generated-artifact",
|
||||
"authority": {
|
||||
"kind": "frontend-env-schema",
|
||||
"source_root": "web",
|
||||
"model": "web/env.ts"
|
||||
},
|
||||
"variables": [
|
||||
{
|
||||
"name": "NEXT_PUBLIC_ALLOW_EMBED",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_ALLOW_EMBED"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "boolean",
|
||||
"description": "Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking",
|
||||
"code_default": false,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "allowEmbed"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_ALLOW_INLINE_STYLES",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_ALLOW_INLINE_STYLES"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "boolean",
|
||||
"description": "Allow inline style attributes in Markdown rendering. Self-hosted opt-in for workflows using styled Jinja2 templates.",
|
||||
"code_default": false,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "allowInlineStyles"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "boolean",
|
||||
"description": "Allow rendering unsafe URLs which have \"data:\" scheme.",
|
||||
"code_default": false,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "allowUnsafeDataScheme"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_AMPLITUDE_API_KEY",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_AMPLITUDE_API_KEY"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "The API key of amplitude",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "amplitudeApiKey"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_API_PREFIX",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_API_PREFIX"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "The base URL of console application, refers to the Console base URL of WEB service if console domain is different from api or web app domain. example: http://cloud.dify.ai/console/api",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "apiPrefix"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_BASE_PATH",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_BASE_PATH"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "The base path for the application",
|
||||
"code_default": "",
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "basePath"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_BATCH_CONCURRENCY",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_BATCH_CONCURRENCY"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "integer",
|
||||
"description": "number of concurrency",
|
||||
"code_default": 5,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "batchConcurrency"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_COOKIE_DOMAIN",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_COOKIE_DOMAIN"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1.",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "cookieDomain"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_CSP_WHITELIST",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_CSP_WHITELIST"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "CSP https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "cspWhitelist"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_DEPLOY_ENV",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_DEPLOY_ENV"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "literal[\"DEVELOPMENT\", \"PRODUCTION\", \"TESTING\"]",
|
||||
"description": "For production release, change this to PRODUCTION",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "deployEnv"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "boolean",
|
||||
"description": "",
|
||||
"code_default": false,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "disableUploadImageAsIcon"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_EDITION",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_EDITION"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "literal[\"SELF_HOSTED\", \"CLOUD\"]",
|
||||
"description": "The deployment edition, SELF_HOSTED",
|
||||
"code_default": "SELF_HOSTED",
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "edition"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "boolean",
|
||||
"description": "Enable inline LaTeX rendering with single dollar signs ($...$) Default is false for security reasons to prevent conflicts with regular text",
|
||||
"code_default": false,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "enableSingleDollarLatex"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "boolean",
|
||||
"description": "",
|
||||
"code_default": true,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "enableWebsiteFirecrawl"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "boolean",
|
||||
"description": "",
|
||||
"code_default": true,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "enableWebsiteJinareader"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "boolean",
|
||||
"description": "",
|
||||
"code_default": false,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "enableWebsiteWatercrawl"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "integer",
|
||||
"description": "The maximum number of tokens for segmentation",
|
||||
"code_default": 4000,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "indexingMaxSegmentationTokensLength"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_IS_MARKETPLACE",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_IS_MARKETPLACE"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "boolean",
|
||||
"description": "",
|
||||
"code_default": false,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "isMarketplace"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_LOOP_NODE_MAX_COUNT",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_LOOP_NODE_MAX_COUNT"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "integer",
|
||||
"description": "Maximum loop count in the workflow",
|
||||
"code_default": 100,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "loopNodeMaxCount"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_MAINTENANCE_NOTICE",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_MAINTENANCE_NOTICE"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "maintenanceNotice"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_MARKETPLACE_API_PREFIX",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_MARKETPLACE_API_PREFIX"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "The API PREFIX for MARKETPLACE",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "marketplaceApiPrefix"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_MARKETPLACE_URL_PREFIX",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_MARKETPLACE_URL_PREFIX"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "The URL for MARKETPLACE",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "marketplaceUrlPrefix"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_MAX_ITERATIONS_NUM",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_MAX_ITERATIONS_NUM"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "integer",
|
||||
"description": "The maximum number of iterations for agent setting",
|
||||
"code_default": 99,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "maxIterationsNum"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_MAX_PARALLEL_LIMIT",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_MAX_PARALLEL_LIMIT"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "integer",
|
||||
"description": "Maximum number of Parallelism branches in the workflow",
|
||||
"code_default": 10,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "maxParallelLimit"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_MAX_TOOLS_NUM",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_MAX_TOOLS_NUM"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "integer",
|
||||
"description": "Maximum number of tools in the agent/workflow",
|
||||
"code_default": 10,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "maxToolsNum"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_MAX_TREE_DEPTH",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_MAX_TREE_DEPTH"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "integer",
|
||||
"description": "The maximum number of tree node depth for workflow",
|
||||
"code_default": 50,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "maxTreeDepth"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_PUBLIC_API_PREFIX",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_PUBLIC_API_PREFIX"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "The URL for Web APP, refers to the Web App base URL of WEB service if web app domain is different from console or api domain. example: http://udify.app/api",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "publicApiPrefix"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_SENTRY_DSN",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_SENTRY_DSN"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "SENTRY",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "sentryDsn"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_SITE_ABOUT",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_SITE_ABOUT"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "siteAbout"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_SOCKET_URL",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_SOCKET_URL"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "socketUrl"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_SUPPORT_EMAIL_ADDRESS",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_SUPPORT_EMAIL_ADDRESS"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "supportEmailAddress"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_SUPPORT_MAIL_LOGIN",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_SUPPORT_MAIL_LOGIN"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "boolean",
|
||||
"description": "",
|
||||
"code_default": false,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "supportMailLogin"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "integer",
|
||||
"description": "The timeout for the text generation in millisecond",
|
||||
"code_default": 60000,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "textGenerationTimeoutMs"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_TOP_K_MAX_VALUE",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_TOP_K_MAX_VALUE"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "integer",
|
||||
"description": "The maximum number of top-k value for RAG.",
|
||||
"code_default": 10,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "topKMaxValue"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "boolean",
|
||||
"description": "Disable Upload Image as WebApp icon default is false",
|
||||
"code_default": false,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "uploadImageAsIcon"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_WEB_PREFIX",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_WEB_PREFIX"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "webPrefix"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "zendeskFieldIdEmail"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "zendeskFieldIdEnvironment"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "zendeskFieldIdPlan"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "zendeskFieldIdVersion"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "zendeskFieldIdWorkspaceId"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_ZENDESK_WIDGET_KEY",
|
||||
"accepted_names": [
|
||||
"NEXT_PUBLIC_ZENDESK_WIDGET_KEY"
|
||||
],
|
||||
"runtime": "client",
|
||||
"visibility": "browser-public",
|
||||
"type": "string",
|
||||
"description": "",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "body-dataset",
|
||||
"dataset_key": "zendeskWidgetKey"
|
||||
},
|
||||
{
|
||||
"name": "INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH",
|
||||
"accepted_names": [
|
||||
"INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH"
|
||||
],
|
||||
"runtime": "server",
|
||||
"visibility": "server-only",
|
||||
"type": "integer",
|
||||
"description": "Maximum length of segmentation tokens for indexing",
|
||||
"code_default": 4000,
|
||||
"required": false,
|
||||
"injection_mode": "process-env",
|
||||
"dataset_key": null
|
||||
},
|
||||
{
|
||||
"name": "NEXT_TELEMETRY_DISABLED",
|
||||
"accepted_names": [
|
||||
"NEXT_TELEMETRY_DISABLED"
|
||||
],
|
||||
"runtime": "server",
|
||||
"visibility": "server-only",
|
||||
"type": "boolean",
|
||||
"description": "Disable Next.js Telemetry (https://nextjs.org/telemetry)",
|
||||
"code_default": null,
|
||||
"required": false,
|
||||
"injection_mode": "process-env",
|
||||
"dataset_key": null
|
||||
},
|
||||
{
|
||||
"name": "PORT",
|
||||
"accepted_names": [
|
||||
"PORT"
|
||||
],
|
||||
"runtime": "server",
|
||||
"visibility": "server-only",
|
||||
"type": "integer",
|
||||
"description": "",
|
||||
"code_default": 3000,
|
||||
"required": false,
|
||||
"injection_mode": "process-env",
|
||||
"dataset_key": null
|
||||
},
|
||||
{
|
||||
"name": "TEXT_GENERATION_TIMEOUT_MS",
|
||||
"accepted_names": [
|
||||
"TEXT_GENERATION_TIMEOUT_MS"
|
||||
],
|
||||
"runtime": "server",
|
||||
"visibility": "server-only",
|
||||
"type": "integer",
|
||||
"description": "The timeout for the text generation in millisecond",
|
||||
"code_default": 60000,
|
||||
"required": false,
|
||||
"injection_mode": "process-env",
|
||||
"dataset_key": null
|
||||
}
|
||||
]
|
||||
}
|
||||
64
web/docs/frontend-env.reference.md
Normal file
64
web/docs/frontend-env.reference.md
Normal file
@ -0,0 +1,64 @@
|
||||
# Frontend Env Reference
|
||||
|
||||
> Generated from `web/env.ts`. Do not edit manually.
|
||||
|
||||
This reference documents frontend application env semantics and code defaults only.
|
||||
Deploy-time defaults, `.env.example`, Docker files, and runtime-effective values are intentionally excluded.
|
||||
Only env declared in `web/env.ts` is included. Dev-only tooling env outside that file is excluded.
|
||||
|
||||
## Browser-Public Variables
|
||||
|
||||
| Name | Visibility | Type | Default | Injection | Dataset Key | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `NEXT_PUBLIC_ALLOW_EMBED` | `browser-public` | `boolean` | `false` | `body-dataset` | `allowEmbed` | Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking |
|
||||
| `NEXT_PUBLIC_ALLOW_INLINE_STYLES` | `browser-public` | `boolean` | `false` | `body-dataset` | `allowInlineStyles` | Allow inline style attributes in Markdown rendering. Self-hosted opt-in for workflows using styled Jinja2 templates. |
|
||||
| `NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME` | `browser-public` | `boolean` | `false` | `body-dataset` | `allowUnsafeDataScheme` | Allow rendering unsafe URLs which have "data:" scheme. |
|
||||
| `NEXT_PUBLIC_AMPLITUDE_API_KEY` | `browser-public` | `string` | `""` | `body-dataset` | `amplitudeApiKey` | The API key of amplitude |
|
||||
| `NEXT_PUBLIC_API_PREFIX` | `browser-public` | `string` | `""` | `body-dataset` | `apiPrefix` | The base URL of console application, refers to the Console base URL of WEB service if console domain is different from api or web app domain. example: http://cloud.dify.ai/console/api |
|
||||
| `NEXT_PUBLIC_BASE_PATH` | `browser-public` | `string` | `""` | `body-dataset` | `basePath` | The base path for the application |
|
||||
| `NEXT_PUBLIC_BATCH_CONCURRENCY` | `browser-public` | `integer` | `5` | `body-dataset` | `batchConcurrency` | number of concurrency |
|
||||
| `NEXT_PUBLIC_COOKIE_DOMAIN` | `browser-public` | `string` | `""` | `body-dataset` | `cookieDomain` | When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1. |
|
||||
| `NEXT_PUBLIC_CSP_WHITELIST` | `browser-public` | `string` | `""` | `body-dataset` | `cspWhitelist` | CSP https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP |
|
||||
| `NEXT_PUBLIC_DEPLOY_ENV` | `browser-public` | `literal["DEVELOPMENT", "PRODUCTION", "TESTING"]` | `""` | `body-dataset` | `deployEnv` | For production release, change this to PRODUCTION |
|
||||
| `NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON` | `browser-public` | `boolean` | `false` | `body-dataset` | `disableUploadImageAsIcon` | |
|
||||
| `NEXT_PUBLIC_EDITION` | `browser-public` | `literal["SELF_HOSTED", "CLOUD"]` | `"SELF_HOSTED"` | `body-dataset` | `edition` | The deployment edition, SELF_HOSTED |
|
||||
| `NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX` | `browser-public` | `boolean` | `false` | `body-dataset` | `enableSingleDollarLatex` | Enable inline LaTeX rendering with single dollar signs ($...$) Default is false for security reasons to prevent conflicts with regular text |
|
||||
| `NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL` | `browser-public` | `boolean` | `true` | `body-dataset` | `enableWebsiteFirecrawl` | |
|
||||
| `NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER` | `browser-public` | `boolean` | `true` | `body-dataset` | `enableWebsiteJinareader` | |
|
||||
| `NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL` | `browser-public` | `boolean` | `false` | `body-dataset` | `enableWebsiteWatercrawl` | |
|
||||
| `NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH` | `browser-public` | `integer` | `4000` | `body-dataset` | `indexingMaxSegmentationTokensLength` | The maximum number of tokens for segmentation |
|
||||
| `NEXT_PUBLIC_IS_MARKETPLACE` | `browser-public` | `boolean` | `false` | `body-dataset` | `isMarketplace` | |
|
||||
| `NEXT_PUBLIC_LOOP_NODE_MAX_COUNT` | `browser-public` | `integer` | `100` | `body-dataset` | `loopNodeMaxCount` | Maximum loop count in the workflow |
|
||||
| `NEXT_PUBLIC_MAINTENANCE_NOTICE` | `browser-public` | `string` | `""` | `body-dataset` | `maintenanceNotice` | |
|
||||
| `NEXT_PUBLIC_MARKETPLACE_API_PREFIX` | `browser-public` | `string` | `""` | `body-dataset` | `marketplaceApiPrefix` | The API PREFIX for MARKETPLACE |
|
||||
| `NEXT_PUBLIC_MARKETPLACE_URL_PREFIX` | `browser-public` | `string` | `""` | `body-dataset` | `marketplaceUrlPrefix` | The URL for MARKETPLACE |
|
||||
| `NEXT_PUBLIC_MAX_ITERATIONS_NUM` | `browser-public` | `integer` | `99` | `body-dataset` | `maxIterationsNum` | The maximum number of iterations for agent setting |
|
||||
| `NEXT_PUBLIC_MAX_PARALLEL_LIMIT` | `browser-public` | `integer` | `10` | `body-dataset` | `maxParallelLimit` | Maximum number of Parallelism branches in the workflow |
|
||||
| `NEXT_PUBLIC_MAX_TOOLS_NUM` | `browser-public` | `integer` | `10` | `body-dataset` | `maxToolsNum` | Maximum number of tools in the agent/workflow |
|
||||
| `NEXT_PUBLIC_MAX_TREE_DEPTH` | `browser-public` | `integer` | `50` | `body-dataset` | `maxTreeDepth` | The maximum number of tree node depth for workflow |
|
||||
| `NEXT_PUBLIC_PUBLIC_API_PREFIX` | `browser-public` | `string` | `""` | `body-dataset` | `publicApiPrefix` | The URL for Web APP, refers to the Web App base URL of WEB service if web app domain is different from console or api domain. example: http://udify.app/api |
|
||||
| `NEXT_PUBLIC_SENTRY_DSN` | `browser-public` | `string` | `""` | `body-dataset` | `sentryDsn` | SENTRY |
|
||||
| `NEXT_PUBLIC_SITE_ABOUT` | `browser-public` | `string` | `""` | `body-dataset` | `siteAbout` | |
|
||||
| `NEXT_PUBLIC_SOCKET_URL` | `browser-public` | `string` | `""` | `body-dataset` | `socketUrl` | |
|
||||
| `NEXT_PUBLIC_SUPPORT_EMAIL_ADDRESS` | `browser-public` | `string` | `""` | `body-dataset` | `supportEmailAddress` | |
|
||||
| `NEXT_PUBLIC_SUPPORT_MAIL_LOGIN` | `browser-public` | `boolean` | `false` | `body-dataset` | `supportMailLogin` | |
|
||||
| `NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS` | `browser-public` | `integer` | `60000` | `body-dataset` | `textGenerationTimeoutMs` | The timeout for the text generation in millisecond |
|
||||
| `NEXT_PUBLIC_TOP_K_MAX_VALUE` | `browser-public` | `integer` | `10` | `body-dataset` | `topKMaxValue` | The maximum number of top-k value for RAG. |
|
||||
| `NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON` | `browser-public` | `boolean` | `false` | `body-dataset` | `uploadImageAsIcon` | Disable Upload Image as WebApp icon default is false |
|
||||
| `NEXT_PUBLIC_WEB_PREFIX` | `browser-public` | `string` | `""` | `body-dataset` | `webPrefix` | |
|
||||
| `NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL` | `browser-public` | `string` | `""` | `body-dataset` | `zendeskFieldIdEmail` | |
|
||||
| `NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT` | `browser-public` | `string` | `""` | `body-dataset` | `zendeskFieldIdEnvironment` | |
|
||||
| `NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN` | `browser-public` | `string` | `""` | `body-dataset` | `zendeskFieldIdPlan` | |
|
||||
| `NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION` | `browser-public` | `string` | `""` | `body-dataset` | `zendeskFieldIdVersion` | |
|
||||
| `NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID` | `browser-public` | `string` | `""` | `body-dataset` | `zendeskFieldIdWorkspaceId` | |
|
||||
| `NEXT_PUBLIC_ZENDESK_WIDGET_KEY` | `browser-public` | `string` | `""` | `body-dataset` | `zendeskWidgetKey` | |
|
||||
|
||||
## Server-Only Variables
|
||||
|
||||
| Name | Visibility | Type | Default | Injection | Dataset Key | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH` | `server-only` | `integer` | `4000` | `process-env` | | Maximum length of segmentation tokens for indexing |
|
||||
| `NEXT_TELEMETRY_DISABLED` | `server-only` | `boolean` | `""` | `process-env` | | Disable Next.js Telemetry (https://nextjs.org/telemetry) |
|
||||
| `PORT` | `server-only` | `integer` | `3000` | `process-env` | | |
|
||||
| `TEXT_GENERATION_TIMEOUT_MS` | `server-only` | `integer` | `60000` | `process-env` | | The timeout for the text generation in millisecond |
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
"dev:inspect": "next dev --inspect",
|
||||
"dev:proxy": "tsx ./scripts/dev-hono-proxy.ts",
|
||||
"dev:vinext": "vinext dev",
|
||||
"env:reference": "node ./scripts/env-reference.mjs",
|
||||
"gen-doc-paths": "tsx ./scripts/gen-doc-paths.ts",
|
||||
"gen-icons": "pnpm --filter @dify/iconify-collections generate && node ./scripts/gen-icons.mjs && eslint --fix app/components/base/icons/src/",
|
||||
"i18n:check": "tsx ./scripts/check-i18n.js",
|
||||
|
||||
73
web/scripts/__tests__/env-reference.spec.ts
Normal file
73
web/scripts/__tests__/env-reference.spec.ts
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildFrontendEnvReference, renderFrontendEnvReferenceMarkdown } from '../env-reference.mjs'
|
||||
|
||||
describe('frontend env reference', () => {
|
||||
it('should derive frontend authority metadata from web/env.ts only', () => {
|
||||
// Arrange
|
||||
const reference = buildFrontendEnvReference()
|
||||
const variables = Object.fromEntries(reference.variables.map(variable => [variable.name, variable]))
|
||||
|
||||
// Assert
|
||||
expect(reference.authority.source_root).toBe('web')
|
||||
expect(reference.authority.model).toBe('web/env.ts')
|
||||
expect(variables.NEXT_PUBLIC_API_PREFIX).toBeDefined()
|
||||
expect(variables.HONO_PROXY_HOST).toBeUndefined()
|
||||
expect(variables.HONO_CONSOLE_API_PROXY_TARGET).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should export browser-public dataset metadata for client env variables', () => {
|
||||
// Arrange
|
||||
const reference = buildFrontendEnvReference()
|
||||
const variable = reference.variables.find(item => item.name === 'NEXT_PUBLIC_API_PREFIX')
|
||||
|
||||
// Assert
|
||||
expect(variable).toEqual({
|
||||
name: 'NEXT_PUBLIC_API_PREFIX',
|
||||
accepted_names: ['NEXT_PUBLIC_API_PREFIX'],
|
||||
runtime: 'client',
|
||||
visibility: 'browser-public',
|
||||
type: 'string',
|
||||
description: 'The base URL of console application, refers to the Console base URL of WEB service if console domain is different from api or web app domain. example: http://cloud.dify.ai/console/api',
|
||||
code_default: null,
|
||||
required: false,
|
||||
injection_mode: 'body-dataset',
|
||||
dataset_key: 'apiPrefix',
|
||||
})
|
||||
})
|
||||
|
||||
it('should export server-only process env metadata for server variables', () => {
|
||||
// Arrange
|
||||
const reference = buildFrontendEnvReference()
|
||||
const variable = reference.variables.find(item => item.name === 'PORT')
|
||||
|
||||
// Assert
|
||||
expect(variable).toEqual({
|
||||
name: 'PORT',
|
||||
accepted_names: ['PORT'],
|
||||
runtime: 'server',
|
||||
visibility: 'server-only',
|
||||
type: 'integer',
|
||||
description: '',
|
||||
code_default: 3000,
|
||||
required: false,
|
||||
injection_mode: 'process-env',
|
||||
dataset_key: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render markdown that excludes deploy defaults and explains the scope', () => {
|
||||
// Arrange
|
||||
const markdown = renderFrontendEnvReferenceMarkdown(buildFrontendEnvReference())
|
||||
|
||||
// Assert
|
||||
expect(markdown).toContain('> Generated from `web/env.ts`. Do not edit manually.')
|
||||
expect(markdown).toContain('Deploy-time defaults, `.env.example`, Docker files, and runtime-effective values are intentionally excluded.')
|
||||
expect(markdown).toContain('Only env declared in `web/env.ts` is included. Dev-only tooling env outside that file is excluded.')
|
||||
expect(markdown).toContain('| `NEXT_PUBLIC_API_PREFIX` | `browser-public` | `string` | `""` | `body-dataset` | `apiPrefix` |')
|
||||
expect(markdown).toContain('| `PORT` | `server-only` | `integer` | `3000` | `process-env` | | |')
|
||||
expect(markdown).not.toContain('HONO_PROXY_HOST')
|
||||
})
|
||||
})
|
||||
377
web/scripts/env-reference.mjs
Normal file
377
web/scripts/env-reference.mjs
Normal file
@ -0,0 +1,377 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const envSourcePath = path.join(projectRoot, 'env.ts')
|
||||
const docsRoot = path.join(projectRoot, 'docs')
|
||||
const jsonOutputPath = path.join(docsRoot, 'frontend-env.reference.json')
|
||||
const markdownOutputPath = path.join(docsRoot, 'frontend-env.reference.md')
|
||||
|
||||
/**
|
||||
* @typedef {'client' | 'server'} FrontendEnvRuntime
|
||||
* @typedef {'browser-public' | 'server-only'} FrontendEnvVisibility
|
||||
* @typedef {'body-dataset' | 'process-env'} FrontendEnvInjectionMode
|
||||
*
|
||||
* @typedef {{
|
||||
* name: string
|
||||
* accepted_names: string[]
|
||||
* runtime: FrontendEnvRuntime
|
||||
* visibility: FrontendEnvVisibility
|
||||
* type: string
|
||||
* description: string
|
||||
* code_default: string | number | boolean | null
|
||||
* required: boolean
|
||||
* injection_mode: FrontendEnvInjectionMode
|
||||
* dataset_key: string | null
|
||||
* }} FrontendEnvVariableReference
|
||||
*
|
||||
* @typedef {{
|
||||
* schema_version: string
|
||||
* artifact_policy: string
|
||||
* authority: {
|
||||
* kind: string
|
||||
* source_root: string
|
||||
* model: string
|
||||
* }
|
||||
* variables: FrontendEnvVariableReference[]
|
||||
* }} FrontendEnvReference
|
||||
*/
|
||||
|
||||
const CLIENT_SCHEMA_START = 'const clientSchema = {'
|
||||
const CLIENT_SCHEMA_END = '} satisfies ClientSchema'
|
||||
const SERVER_SCHEMA_START = ' server: {'
|
||||
const SERVER_SCHEMA_END = ' },\n client: clientSchema,'
|
||||
const RUNTIME_ENV_START = ' experimental__runtimeEnv: {'
|
||||
const RUNTIME_ENV_END = ' },\n emptyStringAsUndefined: true,'
|
||||
|
||||
const COMMENT_START = '/**'
|
||||
const COMMENT_END = '*/'
|
||||
const PROPERTY_PATTERN = /^\s*([A-Z][A-Z0-9_]+):\s*(.+),\s*$/
|
||||
const DATASET_PATTERN = /getRuntimeEnvFromBody\('([^']+)'\)/
|
||||
const DEFAULT_PATTERN = /\.default\(([^)]*)\)/
|
||||
const ENUM_PATTERN = /z\.enum\(\[(.+?)\]\)/
|
||||
const STRING_LITERAL_PATTERN = /^(['"])(.*)\1$/
|
||||
|
||||
/**
|
||||
* @param {string} source
|
||||
* @param {string} startMarker
|
||||
* @param {string} endMarker
|
||||
*/
|
||||
function extractBlock(source, startMarker, endMarker) {
|
||||
const startIndex = source.indexOf(startMarker)
|
||||
if (startIndex === -1)
|
||||
throw new Error(`Missing start marker: ${startMarker}`)
|
||||
|
||||
const contentStart = startIndex + startMarker.length
|
||||
const endIndex = source.indexOf(endMarker, contentStart)
|
||||
if (endIndex === -1)
|
||||
throw new Error(`Missing end marker: ${endMarker}`)
|
||||
|
||||
return source.slice(contentStart, endIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} comment
|
||||
*/
|
||||
function normalizeDescription(comment) {
|
||||
return comment
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const trimmedLine = line.trim()
|
||||
if (trimmedLine === '/**' || trimmedLine === '*/')
|
||||
return ''
|
||||
|
||||
return trimmedLine
|
||||
.replace(/^\/\*\*\s?/, '')
|
||||
.replace(/^\*\s?/, '')
|
||||
.replace(/\s*\*\/$/, '')
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} block
|
||||
*/
|
||||
function parseSchemaEntries(block) {
|
||||
/** @type {{ name: string, expression: string, description: string }[]} */
|
||||
const entries = []
|
||||
const lines = block.split('\n')
|
||||
/** @type {string[]} */
|
||||
let commentBuffer = []
|
||||
let isCollectingComment = false
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim()
|
||||
if (!trimmedLine)
|
||||
continue
|
||||
|
||||
if (trimmedLine.startsWith(COMMENT_START)) {
|
||||
isCollectingComment = true
|
||||
commentBuffer = [trimmedLine]
|
||||
if (trimmedLine.endsWith(COMMENT_END))
|
||||
isCollectingComment = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (isCollectingComment) {
|
||||
commentBuffer.push(trimmedLine)
|
||||
if (trimmedLine.endsWith(COMMENT_END))
|
||||
isCollectingComment = false
|
||||
continue
|
||||
}
|
||||
|
||||
const propertyMatch = line.match(PROPERTY_PATTERN)
|
||||
if (!propertyMatch)
|
||||
continue
|
||||
|
||||
const [, name, expression] = propertyMatch
|
||||
entries.push({
|
||||
name,
|
||||
expression: expression.trim(),
|
||||
description: normalizeDescription(commentBuffer.join('\n')),
|
||||
})
|
||||
commentBuffer = []
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} block
|
||||
*/
|
||||
function parseRuntimeDatasetKeys(block) {
|
||||
/** @type {Map<string, string>} */
|
||||
const datasetKeys = new Map()
|
||||
|
||||
for (const line of block.split('\n')) {
|
||||
const propertyMatch = line.match(PROPERTY_PATTERN)
|
||||
if (!propertyMatch)
|
||||
continue
|
||||
|
||||
const [, name, expression] = propertyMatch
|
||||
const datasetMatch = expression.match(DATASET_PATTERN)
|
||||
if (datasetMatch)
|
||||
datasetKeys.set(name, datasetMatch[1])
|
||||
}
|
||||
|
||||
return datasetKeys
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} literal
|
||||
* @returns {string | number | boolean | null}
|
||||
*/
|
||||
function parseDefaultLiteral(literal) {
|
||||
const trimmedLiteral = literal.trim()
|
||||
if (!trimmedLiteral)
|
||||
return null
|
||||
if (trimmedLiteral === 'true')
|
||||
return true
|
||||
if (trimmedLiteral === 'false')
|
||||
return false
|
||||
if (/^-?\d+$/.test(trimmedLiteral))
|
||||
return Number(trimmedLiteral)
|
||||
|
||||
const stringMatch = trimmedLiteral.match(STRING_LITERAL_PATTERN)
|
||||
if (stringMatch)
|
||||
return stringMatch[2]
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} expression
|
||||
*/
|
||||
function inferType(expression) {
|
||||
if (expression.includes('coercedBoolean'))
|
||||
return 'boolean'
|
||||
if (expression.includes('coercedNumber'))
|
||||
return 'integer'
|
||||
|
||||
const enumMatch = expression.match(ENUM_PATTERN)
|
||||
if (enumMatch) {
|
||||
const values = Array.from(enumMatch[1].matchAll(/'([^']+)'|"([^"]+)"/g))
|
||||
.map(match => match[1] || match[2])
|
||||
return `literal[${values.map(value => JSON.stringify(value)).join(', ')}]`
|
||||
}
|
||||
|
||||
if (expression.includes('z.email(') || expression.includes('z.url(') || expression.includes('z.string('))
|
||||
return 'string'
|
||||
|
||||
if (expression.includes('z.literal(')) {
|
||||
const literalMatch = expression.match(/z\.literal\(([^)]*)\)/)
|
||||
if (literalMatch)
|
||||
return `literal[${JSON.stringify(parseDefaultLiteral(literalMatch[1]))}]`
|
||||
}
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} expression
|
||||
*/
|
||||
function inferDefault(expression) {
|
||||
const defaultMatch = expression.match(DEFAULT_PATTERN)
|
||||
if (!defaultMatch)
|
||||
return null
|
||||
return parseDefaultLiteral(defaultMatch[1])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} expression
|
||||
*/
|
||||
function inferRequired(expression) {
|
||||
return !expression.includes('.optional()') && !expression.includes('.default(')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof parseSchemaEntries>[number]} entry
|
||||
* @param {Map<string, string>} datasetKeys
|
||||
* @returns {FrontendEnvVariableReference}
|
||||
*/
|
||||
function toClientVariable(entry, datasetKeys) {
|
||||
return {
|
||||
name: entry.name,
|
||||
accepted_names: [entry.name],
|
||||
runtime: 'client',
|
||||
visibility: 'browser-public',
|
||||
type: inferType(entry.expression),
|
||||
description: entry.description,
|
||||
code_default: inferDefault(entry.expression),
|
||||
required: inferRequired(entry.expression),
|
||||
injection_mode: 'body-dataset',
|
||||
dataset_key: datasetKeys.get(entry.name) || null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof parseSchemaEntries>[number]} entry
|
||||
* @returns {FrontendEnvVariableReference}
|
||||
*/
|
||||
function toServerVariable(entry) {
|
||||
return {
|
||||
name: entry.name,
|
||||
accepted_names: [entry.name],
|
||||
runtime: 'server',
|
||||
visibility: 'server-only',
|
||||
type: inferType(entry.expression),
|
||||
description: entry.description,
|
||||
code_default: inferDefault(entry.expression),
|
||||
required: inferRequired(entry.expression),
|
||||
injection_mode: 'process-env',
|
||||
dataset_key: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | number | boolean | null} value
|
||||
*/
|
||||
function renderDefault(value) {
|
||||
if (value === null)
|
||||
return '`""`'
|
||||
return `\`${JSON.stringify(value)}\``
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | null} value
|
||||
*/
|
||||
function markdownCodeCell(value) {
|
||||
if (!value)
|
||||
return ''
|
||||
return `\`${String(value).replace(/\|/g, '\\|').replace(/`/g, '\\`')}\``
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
function markdownCell(value) {
|
||||
return value.replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FrontendEnvReference} reference
|
||||
*/
|
||||
export function renderFrontendEnvReferenceMarkdown(reference) {
|
||||
/** @type {Record<FrontendEnvRuntime, FrontendEnvVariableReference[]>} */
|
||||
const grouped = {
|
||||
client: [],
|
||||
server: [],
|
||||
}
|
||||
|
||||
for (const variable of reference.variables)
|
||||
grouped[variable.runtime].push(variable)
|
||||
|
||||
const lines = [
|
||||
'# Frontend Env Reference',
|
||||
'',
|
||||
'> Generated from `web/env.ts`. Do not edit manually.',
|
||||
'',
|
||||
'This reference documents frontend application env semantics and code defaults only.',
|
||||
'Deploy-time defaults, `.env.example`, Docker files, and runtime-effective values are intentionally excluded.',
|
||||
'Only env declared in `web/env.ts` is included. Dev-only tooling env outside that file is excluded.',
|
||||
'',
|
||||
]
|
||||
|
||||
for (const runtime of ['client', 'server']) {
|
||||
const variables = grouped[/** @type {FrontendEnvRuntime} */ (runtime)]
|
||||
if (!variables.length)
|
||||
continue
|
||||
|
||||
lines.push(runtime === 'client' ? '## Browser-Public Variables' : '## Server-Only Variables')
|
||||
lines.push('')
|
||||
lines.push('| Name | Visibility | Type | Default | Injection | Dataset Key | Description |')
|
||||
lines.push('| --- | --- | --- | --- | --- | --- | --- |')
|
||||
|
||||
for (const variable of variables) {
|
||||
lines.push(
|
||||
`| \`${variable.name}\` | ${markdownCodeCell(variable.visibility)} | ${markdownCodeCell(variable.type)} | ${renderDefault(variable.code_default)} | ${markdownCodeCell(variable.injection_mode)} | ${markdownCodeCell(variable.dataset_key)} | ${markdownCell(variable.description)} |`,
|
||||
)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function buildFrontendEnvReference() {
|
||||
const source = readFileSync(envSourcePath, 'utf8')
|
||||
const clientEntries = parseSchemaEntries(extractBlock(source, CLIENT_SCHEMA_START, CLIENT_SCHEMA_END))
|
||||
const serverEntries = parseSchemaEntries(extractBlock(source, SERVER_SCHEMA_START, SERVER_SCHEMA_END))
|
||||
const datasetKeys = parseRuntimeDatasetKeys(extractBlock(source, RUNTIME_ENV_START, RUNTIME_ENV_END))
|
||||
|
||||
return {
|
||||
schema_version: '1',
|
||||
artifact_policy: 'committed-generated-artifact',
|
||||
authority: {
|
||||
kind: 'frontend-env-schema',
|
||||
source_root: 'web',
|
||||
model: 'web/env.ts',
|
||||
},
|
||||
variables: [
|
||||
...clientEntries.map(entry => toClientVariable(entry, datasetKeys)),
|
||||
...serverEntries.map(toServerVariable),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function writeFrontendEnvReference() {
|
||||
const reference = buildFrontendEnvReference()
|
||||
mkdirSync(docsRoot, { recursive: true })
|
||||
writeFileSync(jsonOutputPath, `${JSON.stringify(reference, null, 2)}\n`, 'utf8')
|
||||
writeFileSync(markdownOutputPath, `${renderFrontendEnvReferenceMarkdown(reference)}\n`, 'utf8')
|
||||
|
||||
return {
|
||||
jsonOutputPath,
|
||||
markdownOutputPath,
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const { jsonOutputPath: jsonOutput, markdownOutputPath: markdownOutput } = writeFrontendEnvReference()
|
||||
console.log(`Wrote ${path.relative(projectRoot, jsonOutput)}`)
|
||||
console.log(`Wrote ${path.relative(projectRoot, markdownOutput)}`)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user