Skip to content

Commit 06e13d9

Browse files
committed
move distribution PWA tips to doc
1 parent 9af65a2 commit 06e13d9

1 file changed

Lines changed: 159 additions & 20 deletions

File tree

symfony/caddy.md

Lines changed: 159 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,172 @@
33
[The API Platform distribution](index.md) is shipped with [the Caddy web server](https://caddyserver.com).
44
The build contains the [Mercure](../core/mercure.md) and the [Vulcain](https://vulcain.rocks) Caddy modules.
55

6-
Caddy is positioned in front of the web API and of the Progressive Web App.
7-
It routes requests to either service depending on the value of the `Accept` HTTP header or the extension
8-
of the requested file.
6+
Caddy is positioned in front of the web API and of the Progressive Web App (PWA).
7+
It routes requests to either service depending on the value of the `Accept` HTTP header or the path of the request.
98

109
Using the same domain to serve the API and the PWA [improves performance by preventing unnecessary CORS preflight requests
1110
and encourages embracing the REST principles](https://dunglas.fr/2022/01/preventing-cors-preflight-requests-using-content-negotiation/).
1211

13-
By default, requests having an `Accept` request header containing the `text/html` media type are routed to the Next.js application,
14-
except for some paths known to be resources served by the API (e.g. the Swagger UI documentation, static files provided by bundles...).
15-
Other requests are routed to the API.
12+
## Why `route {}` Is Required
1613

17-
Sometimes, you may want to let the PHP application generate HTML responses.
18-
For instance, when you create your own Symfony controllers serving HTML pages,
19-
or when using bundles such as EasyAdmin or SonataAdmin.
14+
Caddy processes directives in a [predefined global order](https://caddyserver.com/docs/caddyfile/directives#directive-order),
15+
not in the order they appear in the Caddyfile. In that order, `rewrite` runs **before** `reverse_proxy`. Without
16+
explicit ordering, a browser request to `/` would match the `@phpRoute` rewrite condition and be rewritten to
17+
`index.php` before Caddy ever evaluated whether the request should be proxied to Next.js.
2018

21-
To do so, you have to tweak the rules used to route the requests.
22-
Open `api-platform/api/frankenphp/Caddyfile` and modify the expression.
23-
You can use [any CEL (Common Expression Language) expression](https://caddyserver.com/docs/caddyfile/matchers#expression) supported by Caddy.
19+
Wrapping the directives in a `route {}` block enforces **strict first-match-wins evaluation in file order**. The first
20+
directive that matches a request wins, and Caddy stops evaluating the rest. This is what makes the `@pwa` proxy check
21+
run before the PHP rewrite:
2422

25-
For instance, if you want to route all requests to a path starting with `/admin` to the API, modify the existing expression like this:
23+
```caddy
24+
route {
25+
# 1. Check @pwa first — proxy to Next.js if matched
26+
reverse_proxy @pwa http://{$PWA_UPSTREAM}
2627
27-
```patch
28-
# Matches requests for HTML documents, for static files and for Next.js files,
29-
# except for known API paths and paths with extensions handled by API Platform
28+
# 2. Only if @pwa did not match, rewrite to index.php
29+
@phpRoute { not path /.well-known/mercure*; not file {path} }
30+
rewrite @phpRoute index.php
31+
32+
# 3. Run PHP for index.php
33+
@frontController path index.php
34+
php @frontController
35+
36+
# 4. Serve remaining static files
37+
file_server { hide *.php }
38+
}
39+
```
40+
41+
## The `@pwa` Matcher
42+
43+
The `@pwa` named matcher is a [CEL (Common Expression Language) expression](https://caddyserver.com/docs/caddyfile/matchers#expression)
44+
that decides which requests are forwarded to the Next.js application:
45+
46+
```caddy
3047
@pwa expression `(
31-
{header.Accept}.matches("\\btext/html\\b")
32-
- && !{path}.matches("(?i)(?:^/docs|^/graphql|^/bundles/|^/_profiler|^/_wdt|\\.(?:json|html$|csv$|ya?ml$|xml$))")
33-
+ && !{path}.matches("(?i)(?:^/admin|^/docs|^/graphql|^/bundles/|^/_profiler|^/_wdt|\\.(?:json|html$|csv$|ya?ml$|xml$))")
34-
)`
48+
header({'Accept': '*text/html*'})
49+
&& !path(
50+
'/docs*', '/graphql*', '/bundles*', '/contexts*', '/_profiler*', '/_wdt*',
51+
'*.json*', '*.html', '*.csv', '*.yml', '*.yaml', '*.xml'
52+
)
53+
)
54+
|| path('/favicon.ico', '/manifest.json', '/robots.txt', '/sitemap*', '/_next*', '/__next*')
55+
|| query({'_rsc': '*'})`
56+
```
57+
58+
The expression has three independent clauses joined by `||`. A request matches `@pwa` if **any** clause is true.
59+
60+
### Clause 1: HTML requests that are not API paths
61+
62+
A browser navigating to any URL sends `Accept: text/html, */*`. This clause forwards those requests to Next.js unless
63+
the path is known to be served by the API or carries an extension that API Platform handles through
64+
[content negotiation](../core/content-negotiation.md).
65+
66+
Paths excluded from Next.js (handled by PHP instead):
67+
68+
| Pattern | Reason |
69+
|---|---|
70+
| `/docs*` | Swagger UI and OpenAPI documentation |
71+
| `/graphql*` | GraphQL endpoint |
72+
| `/bundles*` | Symfony bundle assets published by `assets:install` |
73+
| `/contexts*` | JSON-LD context documents |
74+
| `/_profiler*`, `/_wdt*` | Symfony Web Debug Toolbar and Profiler |
75+
| `*.json*`, `*.html`, `*.csv`, `*.yml`, `*.yaml`, `*.xml` | Content-negotiated formats served by the API |
76+
77+
### Clause 2: Next.js static assets and well-known files
78+
79+
```
80+
path('/favicon.ico', '/manifest.json', '/robots.txt', '/sitemap*', '/_next*', '/__next*')
81+
```
82+
83+
These paths are forwarded to Next.js unconditionally, regardless of the `Accept` header. `/_next/*` and `/__next/*`
84+
are the internal asset paths used by the Next.js runtime for JavaScript chunks, CSS, images, and hot module
85+
replacement updates in development.
86+
87+
### Clause 3: React Server Components requests
88+
89+
```
90+
query({'_rsc': '*'})
91+
```
92+
93+
Next.js uses the `_rsc` query parameter internally for
94+
[React Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components) data
95+
fetching. These requests do not carry `text/html` in their `Accept` header, so they would miss clause 1 without this
96+
dedicated check.
97+
98+
## The `Link` Header
99+
100+
```caddy
101+
header ?Link `</docs.jsonld>; rel="http://www.w3.org/ns/hydra/core#apiDocumentation", </.well-known/mercure>; rel="mercure"`
102+
```
103+
104+
This directive is placed at the **site block level**, outside the `route {}` block, so it applies to every response
105+
regardless of whether it came from PHP or Next.js. The `?` prefix means the header is only set when not already
106+
present in the response — a PHP response that sets its own `Link` header is not overwritten.
107+
108+
Setting this at the Caddy level serves two purposes:
109+
110+
1. **API discoverability**: every response advertises the Hydra API documentation URL, allowing clients to
111+
auto-discover the API.
112+
2. **Mercure subscription**: every response advertises the Mercure hub URL, so clients can subscribe to real-time
113+
updates without any application code.
114+
115+
The Next.js application does not need to set these headers itself — they arrive on every response automatically.
116+
117+
## The `PWA_UPSTREAM` Environment Variable
118+
119+
```caddy
120+
reverse_proxy @pwa http://{$PWA_UPSTREAM}
121+
```
122+
123+
`PWA_UPSTREAM` is resolved at runtime from the container environment. In `compose.yaml` it is set to `pwa:3000`,
124+
where `pwa` is the Docker Compose service name and `3000` is the default port of the Next.js server.
125+
126+
When the `pwa` service is not running (for example in an API-only project), Caddy returns a `502 Bad Gateway` for any
127+
request matching `@pwa`. To run without a Next.js frontend, comment out that line in the Caddyfile:
128+
129+
```caddy
130+
route {
131+
# Comment the following line if you don't want Next.js to catch requests for HTML documents.
132+
# In this case, they will be handled by the PHP app.
133+
# reverse_proxy @pwa http://{$PWA_UPSTREAM}
134+
135+
@phpRoute { not path /.well-known/mercure*; not file {path} }
136+
rewrite @phpRoute index.php
137+
@frontController path index.php
138+
php @frontController
139+
file_server { hide *.php }
140+
}
141+
```
142+
143+
## Adjusting the Routing Rules
144+
145+
### Routing an admin path to PHP
146+
147+
If you use EasyAdmin, SonataAdmin, or a custom Symfony controller that serves HTML pages, add the path prefix to the
148+
exclusion list inside clause 1 so those requests bypass Next.js:
149+
150+
```caddy
151+
@pwa expression `(
152+
header({'Accept': '*text/html*'})
153+
&& !path(
154+
'/admin*',
155+
'/docs*', '/graphql*', '/bundles*', '/contexts*', '/_profiler*', '/_wdt*',
156+
'*.json*', '*.html', '*.csv', '*.yml', '*.yaml', '*.xml'
157+
)
158+
)
159+
|| path('/favicon.ico', '/manifest.json', '/robots.txt', '/sitemap*', '/_next*', '/__next*')
160+
|| query({'_rsc': '*'})`
161+
```
162+
163+
You can use [any CEL expression](https://caddyserver.com/docs/caddyfile/matchers#expression) supported by Caddy.
164+
165+
### Adding a custom API prefix
166+
167+
If your API is mounted under a prefix such as `/api`, add it to the exclusion list:
168+
169+
```caddy
170+
&& !path(
171+
'/api*',
172+
'/docs*', '/graphql*', ...
173+
)
35174
```

0 commit comments

Comments
 (0)