web: SSR outside (commonLayout) ignores SERVER_CONSOLE_API_URL and fetches system-features from http://localhost:80
- 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
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