langgenius / langgenius/dify

web: SSR outside (commonLayout) ignores SERVER_CONSOLE_API_URL and fetches system-features from http://localhost:80

Open
#39,914 2 comments 1 reaction 0 assignees View on GitHub
🐞 bug feat:webapp
Dominant language
TypeScript
Stars
156k
Forks
24.6k
Avg merge
22h 9m
Merged PRs (30d)
610

Description

### Self Checks

- [x] I have read the [Contributing Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) and [Language Policy](https://github.com/langgenius/dify/issues/1542).
- [x] This is only for bug report, if you would like to ask a question, please head to [Discussions](https://github.com/langgenius/dify/discussions/categories/general).
- [x] I have searched for existing issues [search for existing issues](https://github.com/langgenius/dify/issues), including closed ones.
- [x] I confirm that I am using English to submit this report, otherwise it will be closed.
- [x] 【中文用户 & Non English User】请使用英语提交,否则会被关闭 :)
- [x] Please do not modify this template :) and fill in all the required fields.

### Dify version

1.16.0

### Cloud or Self Hosted

Self Hosted (Docker)

### Steps to reproduce

Reproducible with the `web` image alone, using the console URL values that `docker/docker-compose.yaml` and `docker/.env.example` ship as defaults (`CONSOLE_API_URL` empty, `SERVER_CONSOLE_API_URL=http://api:5001`). No API container, no nginx, no reverse proxy required:

```bash
docker run -d --name dify-web-repro -p 13000:3000 \
-e CONSOLE_API_URL= \
-e SERVER_CONSOLE_API_URL=http://api:5001 \
-e APP_API_URL= \
-e MARKETPLACE_API_URL=https://marketplace.dify.ai \
-e MARKETPLACE_URL=https://marketplace.dify.ai \
langgenius/dify-web:1.16.0

curl -s -o /dev/null http://127.0.0.1:13000/signin # 200
curl -s -o /dev/null http://127.0.0.1:13000/apps
docker logs dify-web-repro
```

The two routes resolve the console API differently. `SERVER_CONSOLE_API_URL` is deliberately unresolvable here, which makes the split visible:

| Request | Log line | Target | Error |
| --- | --- | --- | --- |
| `/signin` | `[systemFeatures] fetch failed, using defaults` | `http://localhost/console/api/system-features` | `ECONNREFUSED` |
| `/apps` | `[systemFeatures] server fetch failed` | `http://api:5001` | `getaddrinfo ENOTFOUND api` |

`/apps` honours `SERVER_CONSOLE_API_URL`. `/signin` ignores it and falls back to `http://localhost`, where nothing is listening inside the container. The ratio is exactly 1:1 — three requests to `/signin` produce three log entries.

Log entry emitted by `/signin`, from the container above (stack frames elided):

```
[systemFeatures] fetch failed, using defaults Error [NetworkError]: Request failed due to a network error: GET http://localhost/console/api/system-features
at #d (.next/server/chunks/ssr/_1hkwwg1._.js:1:23783)
...
at async q.call (.next/server/chunks/ssr/web_service_client_ts_116w1e4._.js:1:1357)
request: Request {
method: 'GET',
url: 'http://localhost/console/api/system-features',
...
},
[cause]: TypeError: fetch failed
[cause]: AggregateError:
code: 'ECONNREFUSED',
```

On a Kubernetes deployment where the container has no IPv6 loopback the innermost cause is the more direct `Error: connect ECONNREFUSED 127.0.0.1:80`.

Root cause, traced against the 1.16.0 sources:

1. `web/docker/entrypoint.sh:18` sets `NEXT_PUBLIC_API_PREFIX=${CONSOLE_API_URL}/console/api`. With the shipped default `CONSOLE_API_URL=`, that is the relative `/console/api`.
2. `web/service/client.ts:46-50` — `getBaseURL()` resolves a non-absolute prefix against `'http://localhost'` when `!isClient`. Nothing listens on port 80 inside the `web` container, so the request is refused before it leaves the container.
3. `web/app/(commonLayout)/hydration-boundary.tsx:69` prefetches `serverSystemFeaturesQueryOptions()` — the **server** variant, which resolves `SERVER_CONSOLE_API_URL` via `web/config/server.ts:7` — and dehydrates it at line 82. My reading is that this warm cache is why the client `queryFn` never runs during SSR under that route group; the observed behaviour above is consistent with it.
4. Routes outside that group have no equivalent server-side prefetch, so `useSuspenseQuery(systemFeaturesQueryOptions())` from `web/features/system-features/client.ts:21` executes its `queryFn` during SSR and goes through the browser-facing prefix instead.

Requesting each route against the container above, every one of these produced exactly one log entry:

```
/signin /signup /install /device /forgot-password /reset-password /activate
```

`web/features/system-features/server.ts` already exists and does the right thing — it is simply not wired into these routes.

Everything above was reproduced against the published `langgenius/dify-web:1.16.0` image. On `main`, `web/app/signin/layout.tsx` still calls `useSuspenseQuery(systemFeaturesQueryOptions())` from `@/features/system-features/client`, so the same path appears to remain, though I have not built and run `main` to confirm.

### ✔️ Expected Behavior

Server-side rendering resolves the console API through `SERVER_CONSOLE_API_URL` on every route, as it already does under `(commonLayout)`. With the shipped `.env.example` defaults, the `web` container never attempts to reach `http://localhost:80`.

### ❌ Actual Behavior

Every SSR of an affected route fails the `system-features` fetch and falls back to `defaultSystemFeatures` (`web/features/system-features/config.ts:5`), so `SERVER_CONSOLE_API_URL` has no effect on these routes no matter how it is set.

The practical cost is log volume, because `/signin` is a natural HTTP health check target — `/apps` answers 307 for unauthenticated requests and most load balancers only treat 200 as healthy. On our Kubernetes deployment, with two load balancer backends health-checking `/signin`, the `web` container logged 603 of these entries in a 30-minute window (~20/min) on an otherwise idle instance.

I did not observe a user-visible rendering difference: diffing the SSR'd `/signin` HTML between a container with the default empty `CONSOLE_API_URL` and one with an absolute URL, the only difference was the `data-api-prefix` attribute. Whether a deployment with branding or enforced SSO paints differently on first render, I have not tested.

Setting `CONSOLE_API_URL` to an absolute external URL does silence it — verified, the same container with `CONSOLE_API_URL=https://` logs zero of these. But that contradicts the shipped default, sends server-side render traffic out through the public ingress and back, and cannot be done on the web side alone: `web/config/index.ts:102` derives the CSRF cookie name (`__Host-csrf_token` vs `csrf_token`) from whether `API_PREFIX` starts with `https://`, while the API derives the same name from `libs/token.py:30` `is_secure()`, which requires **both** `CONSOLE_WEB_URL` and `CONSOLE_API_URL` to be `https`. Reading those two together, changing only the web side would rename the cookie the browser reads but not the one the API sets, and the comparison at `libs/token.py:201` would fail. I have not executed that failure case.

Possibly related: #39719 reports SSR log noise from the same layer, but with an `Unauthorized` error — there the request reaches the API. Here it never leaves the container.

Contributor guide

Open the contributing guide

Research direction

Reproduce the issue with the supplied docker run and curl commands, then read web/service/client.ts, web/app/(commonLayout)/hydration-boundary.tsx, web/features/system-features/client.ts, and web/features/system-features/server.ts. Trace the affected route layouts, including web/app/signin/layout.tsx, and compare their SSR behavior with /apps. Done means affected routes use SERVER_CONSOLE_API_URL and no longer request system-features from http://localhost:80.

Written by the indexing model from the issue text.

Assessment

Tech stack
docker, nextjs, typescript
Domain
api, backend, web-dev
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.