Unpoly Server-Side Protocol
Unpoly Series
- Part 1: Fragment replacements
- Unpoly: Achieving a Single-Page Experience with Server-Side Rendering
- Part 2: Server interaction via HTTP headers, Caching
- Unpoly Server-Side Protocol
If you prefer watching over reading, catch the live version of this content from my talk at Devoxx Belgium 2024
Source code for sample application is available at
In its purest form, Unpoly is just a fancy way of replacing normal browser to server interaction.
Optimizing Responses
When reading about Unpoly's "send full pages approach" often one of the objections that comes to mind is "This must be wasteful, this rendering of heaps of markup that gets just thrown away!" That surely might be a valid concern for certain workloads and markup that is expensive to produce.
This is where Unpoly's server protocol
comes to play. It is a set of HTTP request and response headers, that have special meanings. The most basic header is X-Up-Version, it is a request header with which Unpoly identifies itself. So your server knows that the request comes from Unpoly. Along with this header comes X-Up-Target which represents the target Unpoly is aiming for and will replace.
This gives the backend an immediate opportunity to just render what is necessary for the particular response. However, note that Unpoly is also sensitive to standard cache headers. If you changed your response due to presence of X-Up-Target you must add header Vary
in your response:
Vary: X-Up-Target
Content-Type: text/html;charset=UTF-8
... optimized response follows ...
Retargetting responses
Many times the interaction does not follow along the happy path. Take this form for creating new application:
<article>
<form method="post" up-target="main">
<label>Name
<input type="text" id="name" name="name">
</label>
<label>Context Root
<input type="text" id="contextRoot" name="contextRoot">
</label>
<footer>
<button type="submit">Save</button>
</footer>
</form>
</article>
The intent was to update element <main>, but that's not where we want to go when input is invalid. We want to re-render the form instead. This use case is so common that Unpoly got us covered out of the box, we just need to use status code.
Unpoly only replaces the element targeted by up-target when the response status code is 200. When response is in 4xx-5xx range it uses target specified by up-fail-target. This attribute defaults to form and so to display validation error, just return appropriate status code (422 Unprocessable Entity fits just right) and re-render the form also with the validation error.
Now let's remind ourselves the flow of adding a comment from previous article:
The form is set up to append the new comment into comment list by using the modifier :after.
<section>
<h5>Comments</h5>
<ul class="comment-list">
<li><small class="comment-header">No comments</small></li>
</ul>
<form class="add-comment" up-target="section .comment-list:after, .add-comment">
<article>
<header><strong>Add comment</strong></header>
<label>Author<input type="text" name="author"></label>
<label>Comment<textarea name="content"></textarea></label>
<footer>
<button type="submit">Add Comment</button>
</footer>
</article>
</form>
</section>
Imagine what happens when first comment is submitted into this section: comment-list says "No comments" and target selector intends to append to this list. We want the entire list to be replaced in case this is the first comment. Good news is that X-Up-Target works both ways -- as request header it informs server about what Unpoly intends to replace and as response headers server can tell Unpoly what to replace instead. Our backend code looks like this:
public Response newComment(
@PathParam("eventId") int eventId,
@FormParam("author") String author,
@FormParam("content") String content,
@HeaderParam("X-Up-Target") String target) {
var addCommentResult = commentRepository.addEventComment(
eventId, author, content);
if (target != null && target.contains(".comment-list")) {
// It's unpoly request appending to comment list,
// just generate single comment
model.setComments(List.of(addCommentResult.comment()));
var response = Response.ok("comment/list.jte");
if (addCommentResult.totalComments() == 1) {
// if this is the first comment we need to retarget,
// otherwise we'll append to list saying "No Comments".
response.header(
"X-Up-Target", "section .comment-list,.add-comment");
}
return response.build();
}
return Response.seeOther(URI.create("/comment/appevent/"
+ eventId + "/")).build();
}
Immediate validation and dependent fields
Validating upon submission is nice, and is always necessary, even if you would make all your validation on client-side with javascript. In many cases validations require additional data for validation that are not yet part of document and of course Unpoly has a solution that aligns nicely with HTML and HTTP right there:
We place attribute up-validate on an input element:
<label>
Context Root
<input type="text" name="contextRoot" value="${appConfig.getContextRoot().value()}"
aria-invalid="${appConfig.getContextRoot().ariaInvalid()}" up-validate>
<small>${appConfig.getContextRoot().message()}</small>
</label>
On blur Unpoly fires a request with value of the field and a header X-Up-Validate:
POST /app/auth-service/config?edit=true
X-Up-Validate: contextRoot
X-Up-Target: label:has(input[name="contextRoot"])
The content of the header contains the name of the input control and targets its parent element. This is perfect opportunity for backend to validate and place the error message into element <small> after the input. Or to set attribute aria-invalid to false if the input is valid.
up-validate has different use when it is not a boolean attribute. It can define a selector that should be replaced, and the primary use case is to update dependent fields. A good example can be observed here where value of Scalability Type changes rest of the input fields and their options:
Relevant parts of JTE template of this form look something like this, where the radio buttons has up-validate="#scaling-options" and the part of the form with scaling options decides based on properties of scalability type selected.
<article>
<header><strong>Scaling</strong></header>
<fieldset aria-invalid="${appConfig.getScalabilityField().ariaInvalid()}">
<legend>Scaling type</legend>
<label>
<input type="radio" name="scalingType" value="singleton"
checked="${appConfig.getScalabilityField().is("singleton")}" up-validate="#scaling-options">
Singleton
</label>
<label>
<input type="radio" name="scalingType" value="rolling"
checked="${appConfig.getScalabilityField().is("rolling")}" up-validate="#scaling-options">
Rolling upgrade
</label>
<label>
<input type="radio" name="scalingType" value="horizontal"
checked="${appConfig.getScalabilityField().is("horizontal")}" up-validate="#scaling-options">
Horizontal scaling
</label>
</fieldset>
<hr>
<div id="scaling-options">
<label>
Runtime Size
<select name="runtimeSize" aria-invalid="${appConfig.getRuntimeSize().ariaInvalid()}">
@for(var size : appConfig.getAvailableSizes())
<option value="${size}" selected="${appConfig.getRuntimeSize().is(size.name())}">${size.label()}</option>
@endfor
</select>
<small>${appConfig.getRuntimeSize().message()}</small>
</label>
@if (appConfig.getScalabilityType() != null)
@if (appConfig.getScalabilityType().hasDatagrid())
<label>
Data Grid
<select name="datagrid" aria-invalid="${appConfig.getDatagrid().ariaInvalid()}">
<option value="disabled" selected="${appConfig.getDatagrid().is("disabled")}">Disabled</option>
<option value="enabled" selected="${appConfig.getDatagrid().is("enabled")}">Enabled</option>
</select>
<small>${appConfig.getDatagrid().message()}</small>
</label>
@endif
@if (appConfig.getScalabilityType().hasSwitchoverTime())
<label>
Switch-over Time
<select name="switchoverTime" aria-invalid="${appConfig.getSwitchOverTime().ariaInvalid()}">
<option value="0" selected="${appConfig.getSwitchOverTime().is("0")}">Immediate</option>
<option value="60" selected="${appConfig.getSwitchOverTime().is("60")}">Moderate (1 minute)</option>
<option value="300" selected="${appConfig.getSwitchOverTime().is("120")}">Long (5 minutes)</option>
</select>
<small>${appConfig.getSwitchOverTime().message()}</small>
</label>
@endif
@if (appConfig.getScalabilityType().hasReplicas())
<label>
Replicas
<input type="number" name="replicas" value="${appConfig.getReplicas().value()}"
aria-invalid="${appConfig.getReplicas().ariaInvalid()}"
required>
<small>${appConfig.getReplicas().message()}</small>
</label>
@endif
@endif
</div>
Also note the improved business modelling -- we are not doing just plain decision on what the value is, but domain knowledge is utilized whether a particular scalability type requires particular options. Duplicating domain knowledge on frontend only would very likely be considered overengineering.
Caching
Last part of server protocol is proper use of caching headers. Unpoly maintains a client-side cache which is used to make navigation between interactions feel snappier. When a URL is revisited Unpoly will re-render contents of previous response. Then it decides whether it will revalidate, which means whether it will make the actual request. In default setting the request will be skipped if the cached entry is less than 15 seconds old and no POST request have been performed since. This entire behavior is of course configurable globally or per interaction as necessary.
The actual request will then utilize any Last-Modified and ETag headers from cached response so that the server doesn't need to re-render the response if nothing changed.
As mentioned earlier in this article, backend should also include Vary header so that Unpoly will cache responses properly according to the request values.
Let's end the section and the article with one more video example. The charts for application are delayed by 5 seconds for demonstration purposes. At first visit you see 5 seconds on spinning bars and then we see the chart values. At second visit you see the previous chart values which get updated when next response arrives.
Conclusion
Having covered replacement basics in first part , we looked at interaction between Unpoly and the backend server by utilizing HTTP headers:
-
Server-side re-targetting
-
Form field validation
-
Form dependent fields
-
Caching
Next part will take a look at client-side behavior of Unpoly, interaction with third-party javascript, and layers. Stay tuned!