Talking to the server from the frontend: server.request
- Version
- 4.0 preview
- Updated
Audience: anyone writing CBX frontend or admin JavaScript.
TL;DR: server.makeRequest() handed you a jqXHR and left you to figure out what happened.
server.request() hands you an answer. One await, one if, done.
The thirty-second version
Before:
server.makeRequest('reviews', 'storeReview', data)
.done(function(response) {
wrapper.find('.feedback-message').text(response.feedback);
wrapper.find('.review-form').slideUp();
});
After:
const response = await server.request('reviews', 'storeReview', data);
if (!response.ok) {
wrapper.find('.feedback-message').text(response.errors.join(' '));
return;
}
wrapper.find('.feedback-message').text(response.messages.join(' '));
wrapper.find('.review-form').slideUp();
Four lines longer, and it is the difference between a review form that lies to the customer and one that doesn't. Read on for why — the first example is real code, and it has a real bug.
Why the old way quietly broke things
makeRequest returns jQuery's jqXHR. That sounds convenient, and it is, right up until you ask a
simple question: how do I know it worked?
There is no single answer, because CBX endpoints could fail in three completely different ways:
- The request never arrived. Network down, tab closing. →
.fail() - The server refused. Not logged in, validation failed, record in use. →
.done(), because the legacyConfigboxJsonResponseanswered HTTP 200 with{"success": false, …} - It worked. →
.done()
Look at (2) and (3). Both land in .done(). The only thing separating a saved review from a
rejected one was a success field that you had to remember to check.
The example above doesn't check it. So when the server refuses to store a review, that code writes
whatever happens to be in response.feedback into the page and slides the form away as if the
review had been saved. The customer walks off believing they posted a review that does not exist.
Nobody was careless here. The pattern simply makes the wrong thing shorter to write than the right thing, and short wins on a Friday afternoon. That is the actual problem being fixed: not ugliness, but a default that costs you correctness.
It gets sharper. As endpoints migrate to real HTTP status codes, a refusal becomes a genuine 4xx —
which routes to .fail(). Old code with only a .done() handler now does nothing at all: no
message, no error, a button that spins forever. Code that was subtly wrong becomes visibly
broken. Migrating the caller is not optional cleanup; it is what keeps the feature working.
What the server says now
Two shapes, and the HTTP status tells you which — you never have to guess.
Success — 200, or 201 when something was created:
{
"data": { "cartPositionId": 42, "finished": true },
"meta": { "feedback": "Saved." }
}
data is the payload. meta is cross-cutting stuff — the human-facing feedback line lives there.
Note what is absent: there is no success field. The status already said so, and a second source
of truth is just an opportunity for the two to disagree.
Failure — 4xx or 5xx, as RFC 9457 Problem Details:
{
"type": "https://www.configbox.at/api/errors/record-in-use",
"title": "Conflict",
"status": 409,
"code": "RECORD_IN_USE",
"detail": "Could not delete the page, because it contains questions.",
"errors": [
{ "code": "RECORD_IN_USE", "message": "Could not delete the page, because it contains questions." }
],
"validationIssues": []
}
The field that matters most for code is code — a stable, machine-readable identifier
(RECORD_IN_USE, CONFIGURATION_INCOMPLETE, UNKNOWN_ANSWER). Branch on that, never on detail,
which is prose and is translated.
validationIssues carries field-level problems, each {field, code, message}, so a form can put the
message next to the input it belongs to instead of dumping everything at the top.
The server side of this is
ConfigboxApiResponse—ok(),created(),notFound(),conflict(),validationFailed()and friends. Seetechnical/com_configbox_api_contract.md.
What you get back: CbResponse
You don't parse any of the above. server.request() resolves with one normalised object, whichever
shape came back:
| Field | Type | What it is |
|---|---|---|
ok | boolean | Did it work? Exactly "HTTP status below 400", nothing else. |
status | number | The HTTP status. |
data | object | Payload on success. {} otherwise — never null, so no guard needed. |
meta | object | Cross-cutting extras on success (e.g. meta.feedback). |
errors | string[] | Human-readable failure messages, ready to display. [] when ok. |
messages | string[] | Human-readable success messages, ready to display. |
validationIssues | array | {field, code, message} per field problem. [] when ok. |
code | string | Stable machine error code. '' when ok. |
problem | object|null | The raw problem body, if you need something exotic. |
raw | string | The unparsed body, for diagnostics. |
Two properties of this design are worth naming, because they remove entire categories of bug:
Every collection is always a collection. errors, messages and validationIssues are arrays
even on success. You can .join(), .forEach() and .length them without a null check, every time.
The promise resolves for every HTTP status. A 409 is an answer, not a lost request. It rejects only when the request genuinely never completed — network down, request aborted. So the shape of your code is:
let response;
try {
response = await server.request('admincurrencies', 'delete', { ids: '4,7' });
}
catch (e) {
// The request never left the building. Genuinely exceptional.
return;
}
if (!response.ok) { /* the server answered, and said no */ }
…and in practice you rarely write the try at all, because a dead network is not usually something
a click handler can do anything about.
Reacting to a response
The everyday shape
This is the great majority of call sites. Real code, from adminCurrencies.js:
const response = await server.request('admincurrencies', task, {
ids: kenedo.getCheckedListItemIds(list).join(',')
});
if (!response.ok) {
kenedo.showResponseMessages(list, response.errors, response.messages);
return;
}
kenedo.reloadListWithMessages(list, response);
Guard clause, early return, happy path unindented at the bottom. No nesting, no .done/.fail
split, no reading a success field.
Reading the payload
const response = await server.request('configuratorpage', 'getConfiguration', {
cartPositionId: positionId
});
if (!response.ok) {
return;
}
// `data` is always an object, so this needs no guard.
const selections = response.data.selections || [];
Field-level validation
if (!response.ok) {
response.validationIssues.forEach(function(issue) {
form.find('[name="' + issue.field + '"]')
.closest('.form-group')
.addClass('has-error')
.find('.validation-message-target')
.text(issue.message);
});
// Anything not tied to a field still belongs somewhere visible.
if (response.validationIssues.length === 0) {
showErrors(response.errors);
}
return;
}
Branching on why it failed
This is what stable codes are for:
if (!response.ok) {
if (response.code === 'CONFIGURATION_INCOMPLETE') {
// Ride-along detail: which questions are still open.
jumpToFirstOpenQuestion(response.problem.errors);
return;
}
if (response.code === 'AUTHENTICATION_REQUIRED') {
showLoginPrompt();
return;
}
showErrors(response.errors);
return;
}
Never branch on detail or on errors[0]. Those are prose, they are translated, and they will be
reworded by someone who has no idea your if depends on the exact wording.
Several calls in a row
The part that used to be genuinely unpleasant — sequencing — is now boring:
const started = await server.request('configuratorpage', 'startConfiguration', { prod_id: id });
if (!started.ok) {
return showErrors(started.errors);
}
const positionId = started.data.cartPositionId;
const selected = await server.request('configuratorpage', 'setSelection', {
cartPositionId: positionId,
questionId: questionId,
selection: answerId
});
if (!selected.ok) {
return showErrors(selected.errors);
}
Try writing that with nested .done() callbacks and you will remember why nobody enjoyed it.
Three doors, one answer
All three resolve with the same CbResponse. Pick by what you're sending:
| Use | When |
|---|---|
server.request(controller, task, data) | The normal way. Builds the URL from controller + task. |
server.requestUrl(url, data) | You were handed a finished URL by the server and rebuilding it client-side would mean duplicating the host's SEF routing. The quick-edit toolbar is the case. |
server.submitForm(formElement, extraData) | A real Kenedo detail form, posted as FormData to its own action — the one that carries file inputs. |
Because the return shape is identical, the code that reacts to them is identical too. That is the
whole point of a choke point: exactly one place in the product understands what a CBX endpoint
can answer, and it is server.normaliseResponse().
The one real gap: request() sends ordinary parameters, not File objects. If you are uploading
a file outside a Kenedo form, makeRequest() still has bespoke File/FileList handling that
request() does not. That is the single case where reaching for the old function is defensible
today.
Migrating a call site
A recipe that fits most of them:
- Make the enclosing function
async. const response = await server.request(...)in place ofserver.makeRequest(...).- Move the
.done(...)body down, unindented. - Add
if (!response.ok) { …show response.errors…; return; }above it. - Replace
response.foowithresponse.data.foo— the payload moved underdata. - Replace
response.feedbackwithresponse.messages(an array) orresponse.meta.feedback. - A
.always(...)becomes afinally { }block.
Worked, on the broken review form from the top:
// BEFORE
server.makeRequest('reviews', 'storeReview', data)
.done(function(response) {
wrapper.find('.feedback-message').text(response.feedback);
wrapper.find('.review-form').slideUp();
})
.always(function() {
button.removeClass('processing');
});
// AFTER
try {
const response = await server.request('reviews', 'storeReview', data);
if (!response.ok) {
wrapper.find('.feedback-message').text(response.errors.join(' '));
return; // form stays open, so the customer can retry
}
wrapper.find('.feedback-message').text(response.messages.join(' '));
wrapper.find('.review-form').slideUp();
}
finally {
button.removeClass('processing');
}
The bug is gone, and it is gone structurally — not because this version remembers to check, but
because ok is the first thing you look at and errors is already a displayable array.
⚠️ One AMD trap, and it is a nasty one
RequireJS silently discards an async module callback. No error, no warning, the module simply
never initialises:
// BROKEN — the callback never runs, and nothing tells you
cbrequire(['configbox/server'], async function(server) { … });
Keep the module callback synchronous and put async on the function inside it:
cbrequire(['configbox/server'], function(server) {
cbj(document).on('click', '.my-button', async function() {
const response = await server.request('mycontroller', 'mytask', {});
…
});
});
This has already cost real debugging time. If a handler you just wrote appears to do absolutely nothing, check this before anything else.
Things not to do
Don't read success. On a migrated endpoint the field is gone, and undefined is falsy — so
if (response.success) turns every successful call into a failure. The status is the outcome.
Don't add .fail() to request(). It returns a native promise, not a jqXHR. Failures arrive as
a resolved CbResponse with ok: false.
Don't parse the body yourself. If you find yourself reaching into response.raw or
response.problem for something routine, that is a gap in normaliseResponse() — fix it there, once,
for everybody.
Don't branch on message text. Use response.code.
During the migration, both shapes are alive
Not every endpoint has been migrated. normaliseResponse() handles both:
- migrated — the status is the outcome; 2xx is
{data, meta}, 4xx/5xx is problem details - legacy — always 200, with
ConfigboxJsonResponse's{success, errors, messages}
For a legacy endpoint it does read success, because on that shape it genuinely is the outcome.
Either way you get the same CbResponse, so your calling code does not need to know which kind of
endpoint it is talking to — and it will not need changing when that endpoint migrates. Adopting
server.request() now is how you avoid a second pass later.
Where to look next
technical/com_configbox_api_contract.md— the envelope, the status codes, the stable error codestechnical/com_configbox_runtime_api.md— configuring a product and working the cart headlesslycustomization/com_configbox_assets_and_amd.md— how frontend JS is loaded and overridden