@cap2ui5/cds-plugin
v0.5.0
Published
abap2UI5 for CAP: a cds-plugin that hosts the transpiled abap2UI5 runtime, keeps its drafts in a CDS entity and lets apps be written as plain JavaScript classes
Maintainers
Readme
@cap2ui5/cds-plugin
abap2UI5 as a CAP plugin. Build
SAPUI5 apps inside your CAP backend, as plain JavaScript classes — no frontend
project, no manifest.json, no second build pipeline.
The framework underneath is not a port: it is abap2UI5's own ABAP, downported
and transpiled by @abaplint/transpiler over open-abap and published by
abap2UI5 as @abap2ui5/node-runtime. Its drafts are a CDS entity in your database, under your
authorization; your apps read your entities with cds.ql.
Pre-release. The plugin is tested end to end (wire, restart, concurrency, browser) but the API can still move before 1.0.
Install
npm i @cap2ui5/cds-plugin
cds add cap2ui5 # optional: a first app in srv/apps/hello.jsUp to 0.2.0 the package was called cap2ui5. A project that has it swaps
it - npm rm cap2ui5 && npm add @cap2ui5/cds-plugin - and its app modules
require @cap2ui5/cds-plugin; everything it configured keeps its name.
Requires Node.js 22 or later and @sap/cds 9 or 10 (CI tests both, on
Node 22 and 24).
That is the installation. On the next cds serve the roundtrip route
(/rest/root/z2ui5, /sap/bc/z2ui5) exists - its page carries the whole UI5
frontend - and
cds deploy creates cap2ui5.Drafts next to your own entities. Your
server.js, if you have one, is untouched.
An app is one file
// srv/apps/books.js
import cds from "@sap/cds";
import { defineApp, t } from "@cap2ui5/cds-plugin";
const { SELECT } = cds.ql;
defineApp("BOOKS", class {
search = "";
books = t.table({ ID: 0, title: "", author: "", price: t.packed(9, 2) });
async main(client) { // async only because THIS app does I/O
if (client.check_on_navigated()) {
client.view_display(`<mvc:View xmlns:mvc="sap.ui.core.mvc" xmlns="sap.m" displayBlock="true" height="100%">
<Page title="Books">
<SearchField value="${client._bind("search")}" search="${client._event("SEARCH")}"/>
<Table items="${client._bind("books")}"> … <Text text="{TITLE}"/> … </Table>
</Page></mvc:View>`);
} else if (client.check_on_event("SEARCH")) {
const { Books } = cds.entities("my.bookshop");
this.books = await SELECT.from(Books).where`title like ${"%" + this.search + "%"}`;
client.message_toast_display(`${this.books.length} found`);
}
}
});cds watch prints the address of every app of the project, one line for
each package that brings apps (see Apps from a package),
and the user to log in as:
[cap2ui5] - BOOKS http://localhost:4004/sap/bc/z2ui5?app_start=BOOKS
[cap2ui5] - @cap2ui5/samples 71 apps - listed on CAP's start page, http://localhost:4004/
[cap2ui5] - development login: alice (empty password)Only in development; a production profile prints neither. CAP's start page
at / lists the same addresses under "Web Applications".
A project from cds init + cds add nodejs is an ES module project, hence
import. In a CommonJS project, or in a .cjs file,
require("@cap2ui5/cds-plugin") returns the same names.
The client is z2ui5_if_client, by its own names
main( client ) receives what an ABAP app's z2ui5_if_app~main( client )
receives, spelled the JavaScript way: client->check_app_prev_stack( ) is
client.check_app_prev_stack(). A method's preferred parameter is its one
positional argument, and parameters by name are one object with the ABAP
names. The constants are there too. So an ABAP app ports line by line, and
abap2UI5's documentation of a method is
the documentation of the JavaScript one.
| ABAP | JavaScript |
|---|---|
| client->check_on_navigated( ) | client.check_on_navigated() |
| client->check_on_event( `GO` ) | client.check_on_event("GO") |
| client->get_event_arg( 1 ) | client.get_event_arg(1) |
| client->_bind( name ), _bind( s_order-customer ) | client._bind("name"), client._bind("s_order-customer") |
| client->_bind( val = t_tab path = abap_true ) | client._bind({ val: "t_tab", path: true }) |
| client->_event( val = `GO` t_arg = VALUE #( ( `x` ) ) ) | client._event({ val: "GO", t_arg: ["x"] }) |
| client->follow_up_action( val = z2ui5_if_client=>cs_event-set_title t_arg = … ) | client.follow_up_action({ val: z2ui5_if_client.cs_event.set_title, t_arg: [ … ] }) |
| client->view_display( view->stringify( ) ) | client.view_display(view.stringify()) |
| client->message_box_display( text = … type = `error` ) | client.message_box_display({ text: …, type: "error" }) |
| client->nav_app_call( NEW zcl_other( ) ) | client.nav_app_call("ZCL_OTHER") |
| client->nav_app_leave( event = … r_data = … ) | client.nav_app_leave({ event, r_data }) |
| client->get( )-r_event_data | client.get().r_event_data |
Every method of the interface is there under its name - nav.test.mjs holds
the client to the interface, so none is missing and none is invented. The
constants are read from the runtime, and so is which of them there are:
abap2UI5's page transitions - cs_transition, and view_display( )'s
transition and transition_back - come with abap2UI5 1.146.0, the runtime
this version pins; on an older runtime (1.145.0) a call that sets one is
refused, naming the runtime, rather than dropped unseen:
client.view_display({ val: view.stringify(), transition: client.cs_transition.slide });What is JavaScript's own, and why:
- A field is bound by its name,
client._bind("name"): ABAP's_bind( )finds the attribute by reference, which a JavaScript value cannot carry. A cell of a table is{ val: column, tab: "t_tab", tab_index: 2 }. - Some answers come after
main( ). The framework's calls are asynchronous here, somain( )stays synchronous by resolving what it can before it runs and the rest after it: what_event( ), a_bind( )with options, andfollow_up_action( )in a view attribute return is a placeholder that becomes the wire aftermain( )- embed it as it is. Anasync mainworks as well, for an app that does I/O. client.get_app( id )answers the app behind a draft id as a handle whose fields can be written -app.backend_event = "…", thenclient.nav_app_leave(app)- but not read: it is loaded aftermain( ). Reading the other app isclient.get_app_prev( ), as plain values.client.nav_app_call( app, fields )presets the called app's fields, what an ABAP app does betweenNEWandnav_app_call( ).client.set_session_stateful( )is not supported and throws; the interface's obsolete*_model_update( )do nothing, as they do in ABAP. (A transpiled ABAP app can go stateful: its session is kept per CAP user, in the process - a restart or a second instance loses it.)client.rawis the transpiledz2ui5_if_clientitself, asynchronous.
check_on_navigated( ), not check_on_init( ), is the render branch.
check_on_init( ) is the first roundtrip of this app instance;
check_on_navigated( ) is also true every time the app gets the screen back
— a called app leaving, a value help closing, a bookmark restored.
State: strings, numbers, booleans, t.packed(l, d), t.char(n), t.numc(n), t.date(), t.time(), a plain
object (a structure), t.table({ …one row… }) — and those nest, up to 8
levels. Component names are UPPERCASE in the model. The whole instance is
persisted to cap2ui5.Drafts after every roundtrip and rebuilt before the
next, so state survives a restart. As in ABAP, only what the app binds is
part of the model: a field goes to the browser, and the browser can write it,
once main( ) has bound it with client._bind( ) (or _bind_edit( ),
_bind_path( ), a component or a cell of it) - and stays bound for the rest
of the session. A field no view binds - a price, a role flag, a counter -
stays on the server, in the draft; a MODEL the browser sends for it is
ignored. A helper that needs the client gets it as an ABAP app does,
this.client = client in main( ), without declaring it as a field.
A field is an ABAP attribute, so its name is lower case - is_admin, not
isAdmin, which defineApp( ) refuses: ABAP names are not case-sensitive and
the runtime reads the attribute by its lower-case name. A structure's
components and the app's methods may be camelCase. main( ) and its helpers
run on a proxy that reads the fields as plain values, which a #private
member does not reach - using one is refused with a message; a plain field
is private enough, as it is not sent to the browser unless it is bound.
t.bool( ) reads as a boolean, t.char(n) without its padding and
t.float( ) as a number, as the plain true, "" and 0.5 do. t.date( )
and t.time( ) store and read YYYYMMDD and HHMMSS, as ABAP does, and
take a cds.Date's YYYY-MM-DD and a cds.Time's HH:MM:SS as well; the
model carries both as ISO, as the framework serializes a D and a T. null and
undefined clear a field, as CLEAR does; a value a field cannot take - a
text into a number, a string into a table, a JavaScript Date into a
t.date( ) - is refused with the field's name.
Types: the package ships TypeScript declarations (index.d.ts). In a
JavaScript app, annotate the client for completion and checked field names:
/** @param {import("@cap2ui5/cds-plugin").Client<{ search: string }>} client */.
Building a view with z2ui5_cl_ui5_view_builder
A view is built the way an ABAP app builds one, with abap2UI5's own view builder, under its own name and called as the client is:
const { defineApp, z2ui5_cl_ui5_view_builder } = require("@cap2ui5/cds-plugin");
defineApp("HELLO", class {
name = "";
main(client) {
if (client.check_on_navigated()) {
const view = z2ui5_cl_ui5_view_builder.factory()
.ele({ n: "View", ns: "mvc" })
.a({ n: "xmlns", v: "sap.m" })
.a({ n: "xmlns:mvc", v: "sap.ui.core.mvc" })
.ele("Shell")
.ele("Page")
.a({ n: "title", v: "Hello" })
.tag("Input")
.a({ n: "value", v: client._bind("name") })
.tag("Text")
.a({ n: "text", t: "{shown as typed}" })
.tag("Button")
.a({ n: "text", v: "Go" })
.a({ n: "press", v: client._event("GO") });
client.view_display(view.stringify());
} else if (client.check_on_event("GO")) {
client.message_box_display(`Hello ${this.name}`);
}
}
});| | |
|---|---|
| z2ui5_cl_ui5_view_builder.factory() | an empty root; open the mvc:View and declare its xmlns yourself |
| ele(n), ele({ n, ns }) | add a child element and descend into it |
| tag(n), tag({ n, ns }) | add a child element and stay: the form for a leaf |
| a({ n, v }), a({ n, b }), a({ n, t }) | an attribute on the element the chain points at: the child just added, or the node itself while it has none. Exactly one of v (written as it is: bindings, events, constant text), b (a boolean, rendered true/false) and t (text rendered literally, so a { in user input is shown rather than read as a binding) |
| end() | ascend to the parent |
| stringify() | the XML - rendered after main( ), so hand it to view_display( ) as it is; outside an app, await it |
| z2ui5_cl_ui5_view_builder.escape_literal(val) | the literal escaping of t, for one part of a value that also carries a binding |
The chain is recorded while main( ) runs and rendered after it, by the
transpiled z2ui5_cl_ui5_view_builder the runtime carries. The XML, its
escaping and its refusals (an end() past the root, a duplicate attribute, an
invalid name) are therefore exactly those of the same chain in an ABAP app. A
refusal answers the roundtrip with the framework's error, naming the app.
ViewBuilder is the same class, for code that prefers a JavaScript name.
An ABAP app, translated: cap2ui5 abap2js
Because the client and the view builder are abap2UI5's own, an abap2UI5 app class translates into a cap2UI5 app line for line - and the package does it:
npm add -D @abaplint/core # once: the ABAP parser it reads with
npx --no-install cap2ui5 abap2js src/z2ui5_cl_my_app.clas.abap --out srv/appsThe parser, @abaplint/core, is an optional peer dependency of the plugin:
8 MB that only translating needs, so a project that serves apps does not
install it. Without it the command, and abap2js( ) in code, say how to add
it.
--no-install makes npx run the cap2ui5 of the project's
@cap2ui5/cds-plugin or fail - never download one. Without it, npx cap2ui5
where the plugin is not installed fetches and runs whatever npm has under the
unscoped name cap2ui5, and that name is no longer this package's. In a
package.json script the command is cap2ui5 abap2js …, which runs the
installed one as well.
z2ui5_cl_my_app.clas.abap becomes srv/apps/z2ui5_cl_my_app.js, registered
as Z2UI5_CL_MY_APP, so ?app_start= is the same on both sides. The module
starts where the ABAP statement starts and breaks where the ABAP breaks: a
view chain keeps one call per line, VALUE #( ) one row per line, comments
come along and texts are never touched. A directory translates every class in
it.
| option | |
|---|---|
| --out <dir> | where the modules go, srv/apps by default |
| --lib <dir> | where other classes a class names are read from - zcl_other=>ty_s_row (repeatable; the inputs' directories by default) |
| --origin <text> | writes // @origin <text> <input path> into each module |
| --esm, --cjs | the module format; by default what the package.json nearest to --out declares |
| --check | writes nothing and exits 1 when a module is missing or would change - for CI |
It knows the part of ABAP an abap2UI5 app is written in - attributes and
TYPES, VALUE #( ), COND/SWITCH, string templates, IF/CASE/DO,
the client's and the view builder's calls - and refuses everything else
with file, row and column (refused: z2ui5_cl_x.clas.abap:41:7 - LOOP AT ...
ASSIGNING / REFERENCE INTO writes through the row - not supported yet)
rather than guess. What it writes where JavaScript differs from ABAP:
_bind( s_order-customer )is_bind("s_order-customer"); a_bind( )of anything but an attribute or its component is refused.nav_app_call( NEW zcl_other( ) )isnav_app_call("ZCL_OTHER").abap_boolis a boolean; where ABAP turns it into a string - a string template,CONV string( ), at_argrow - it is"X"or"", as ABAP prints it.TYPE p,n,d,tfields aret.packed( ),t.numc( ),t.date( ),t.time( ); a structureTYPESis a module constant, andINCLUDE TYPEof it that constant, spread.- A local ABAP scopes to the method and JavaScript would scope to a block -
declared in a
WHEN, or used after theIF/LOOPthat declares it - is declared on top of the method, starting with the value ABAP gives it. CONV string( )of a number isString( ), without ABAP's trailing sign position ("0 ").
In code, abap2js(source, { file, lib, origin, format }) answers
{ name, code } and throws an Abap2jsError with file, row and col.
How far "line for line" goes was measured on abap2UI5's samples: each
translated sample served beside its original, transpiled, in one cap2UI5
server, and every roundtrip compared - view, model and actions.
Apps from a package
A package can bring apps with it: add it to the project, and its apps run
beside the project's own - as abap2UI5's samples run in the system they are
pulled into. npm add @cap2ui5/samples does exactly that with abap2UI5's
samples.
The package says where its app modules are, in its package.json, and names
the plugin as a peer dependency, so that its apps and the project's run on
the same one:
"cap2ui5": { "apps": "srv/apps" },
"peerDependencies": { "@cap2ui5/cds-plugin": "^0.3.0" }The range moves in lockstep with the plugin's minor version, and stays that
narrow on purpose: ^0.3.0 is 0.3.x only, and while the plugin is 0.x a
minor release may break the API an app is written against (the CHANGELOG
says so, and the release after 0.3.1 does: only bound fields are sent to
the browser, and a camelCase field is refused). A range like >=0.3.0 <1 would let npm install
a plugin the package's apps have never run on, and they would break where
they are used instead of at npm install. So a package with apps publishes
a release for each plugin minor, with the range that names it; from 1.0 on,
^1.0.0 is the range to use. A range the project's plugin does not satisfy
makes npm refuse the install or bring a second, nested copy of the plugin -
which the log names.
The plugin finds such a package the way CAP finds its plugins: among the
project's dependencies, and outside production (NODE_ENV other than
production) among its devDependencies too - so npm add -D brings a
package's apps to development only. Its directory is loaded like the
project's own apps directory, after it, and the log names every package it
loaded apps from. An app of a name the project has already stays the
project's, and the log says so. A directory outside the package, or one the
installed package does not have - a files entry forgotten - is skipped with
a warning.
Agents: the MCP endpoint
AI agents - Claude Code, any client that speaks the
Model Context Protocol over HTTP - can
operate the project's apps: read a screen as data, fill its fields, fire its
events. Through the app's own main( ), as the CAP user who calls, with that
user's drafts and roles; there is no second API and no technical user. It is
the same idea, the same four tools and the same answers as abap2UI5's
MCP server (for the transpiled
backend in development) and abap2UI5's ABAP agent addon (inside an SAP
system).
It is off until the project switches it on:
"cds": { "requires": { "cap2ui5": { "agent": true } } }| key under agent | default | |
|---|---|---|
| path | /rest/root/z2ui5/mcp | where the endpoint answers |
| apps | none | apps the project opts in - names, or patterns with *; how a transpiled ABAP app opts in |
| confirm | none | events an agent hands to a human: "EVENT" for every app, "APP:EVENT" for one, * as a wildcard |
| forbidden | none | events an agent never fires, written the same way |
| retention | 90 | days a row of the audit log cap2ui5.AgentLog is kept; 0 or false keeps every row - see Audit retention |
A key the endpoint does not know, or a value of the wrong type, is refused
at start - "forbiden" would otherwise leave the event open, and a
"retention": "90d" read as "forever" or as "now" would be a wrong guess
for an audit log either way. Any object switches the endpoint on, so
CDS_REQUIRES_CAP2UI5_AGENT_RETENTION=30 alone does too.
An app opts in where it is defined, and classifies its events there:
defineApp("ZCL_JS_BOOKS", class { /* ... */ }, {
agent: { description: "Search the bookshop's books", events: { ADD: "confirm", "DELETE*": "forbidden" } },
});agent: true opts in with every event allowed; agent: false keeps the app
out whatever apps says - for an app that administers something. An event is
allowed, confirm or forbidden; the app's word and the settings' are
both applied and the stricter wins, so a project can tighten what an app
allows but not loosen it. A setting "APP:EVENT" covers the app on the
screen and the app the session was started with, so a framework popup an app
calls (z2ui5_cl_pop_to_confirm) is covered by that app's rules.
Connecting a client - Claude Code, for one:
claude mcp add --transport http bookshop http://localhost:4004/rest/root/z2ui5/mcp \
--header "Authorization: Basic $(printf '%s' 'alice:' | base64)"In production the header is the token your identity provider issues (XSUAA, IAS) - pass it as a header, CAP does not implement MCP's own authorization discovery.
The tools
| tool | input | answer |
|---|---|---|
| app_list | filter? | { count, apps: [{ app, source: "app" \| "config", description? }], hint } |
| app_start | app, values?, max_rows? (0-200, default 20) | snapshot |
| app_describe | session, max_rows? | the snapshot of the last answer - no roundtrip |
| app_act | session, values?, event?, args?, row?, max_rows? | the next snapshot |
The answer is agent snapshot v1, specified in the MCP server's
docs/agent-snapshot.md:
fields (id, model path, label, kind, value, editable, choices), actions
(event, arguments, label, enabled, row scope), tables (columns, the first
rows, selection), messages, texts, unsupported, and pending. It is
not reimplemented here: the plugin carries the MCP server's own
viewxml.mjs, snapshot.mjs and appclient.mjs, vendored unchanged at a
recorded commit (lib/agent/vendor/, npm run agent-vendor), and adds one
key - an action an app or the settings classify confirm or forbidden
carries "policy", as in the ABAP addon. values address a field by id,
model path or name, a table cell as "<table>/<row>/<COLUMN>"; event is an
event name or an action id; values without an event stay pending, as typing
does in the browser. Every refusal is a tool result with isError and a
sentence naming what is allowed, and a refused act sends nothing.
Security model
- Off by default, and an app is reachable only when it opted in or the settings name it.
- The real user, always. The endpoint runs behind CAP's middlewares and
the same
rolesguard as the roundtrip route: 401 with the auth strategy's challenge for anonymous callers, 403 for a user without the role. It admits authenticated users only, even whereroleslets anybody into the UI. A tool call runs the route's own roundtrip handler in process, inside the MCP request, so the draft store binds every draft to that user, and the app's own checks see that user. confirmandforbiddenevents are never fired by an agent. Aconfirmrefusal answers the link that hands the screen to a human:<origin>/rest/root/z2ui5#/app/<APP>/<session>. Opened in the browser by the same user, the framework restores the session's draft - the state the agent prepared - and the human checks it and presses the button. Anybody else gets a fresh app: the draft store serves a draft to its owner only. Values the agent left pending are not in the draft; the refusal lists them.- Sessions are owner-scoped. Each user has a client of their own; a session id of another user's is "unknown", exactly as a missing one.
- Audit: every call writes a row to
cap2ui5.AgentLog- time, user, the MCP client's User-Agent, tool, app, the session in and out, event, arguments, the outcome (ok,refused,confirm,forbidden,error) and the refusal. The values an agent entered are never stored, only which fields it filled. Likecap2ui5.Draftsthe entity is in the model and in no service; to show it, expose it in a service of your own with a@restrictsuch aswhere: 'owner = $user'. Rows older thanretentiondays are deleted - see below. - Browser-side abuse is refused: a request whose
Originnames another host gets 403, and onlyContent-Type: application/jsonis read (415), so a web page cannot drive the endpoint with a user's cookies.
Sessions and restarts
A session is the app's draft - in cap2ui5.Drafts, owned by the user,
expiring with the draft expiry - plus what the client remembers of the
screen: the views in their slots, the pending edits, the last answer
app_describe reads. That memory is the process's (20 sessions per user),
and a restart loses it while the drafts survive. So a session the server no
longer knows is restored from its draft, the way the handover link
restores it for a human: the framework renders the app from the draft
(check_on_navigated( ) is true). app_describe answers the restored screen,
under a new session id; app_act sends nothing and names the new id, since
the screen the agent chose its action on is not in front of it now. Edits
pending before the restart are lost, as a browser tab's are. Only a session
the audit log shows the same user's agent reached, which is the newest of
its line and whose draft has not expired, is restored.
Audit retention
A row of cap2ui5.AgentLog is kept retention days - 90 unless the
setting says otherwise - and then deleted, the way the draft store expires
drafts: one DELETE of everything older than a cutoff, run on the way of an
ordinary request rather than by a timer of the plugin's own. The drafts are
swept on every app start; the log is swept by an agent call, at most once
an hour per process and tenant, so the endpoint pays for one DELETE an
hour. A sweep that fails is logged as a warning and tried again an hour
later; it never fails the agent's call.
"retention": 0 (or false) keeps every row. A project that prefers a
scheduled job of its own sets that, so the endpoint leaves the log alone,
and runs the same DELETE from the job:
const { purgeAgentLog } = require("@cap2ui5/cds-plugin");
const deleted = await purgeAgentLog({ days: 90 }); // without `days`: the setting's retention, else 90It deletes every user's rows older than the cutoff and answers how many. It
runs in cds.context's transaction where there is one - in a multitenant
application, call it per tenant inside cds.tx({ tenant }, ...) (the
endpoint's own sweep follows the caller's tenant).
A session is restored after a restart only from its audit rows, so a
retention shorter than the draft expiry (draft_exp_time_in_hours, 4 hours
unless the user exit changes it) would lose sessions whose drafts are still
there - which takes an expiry of weeks.
Configure
Under cds.requires.cap2ui5 - in package.json, a .cdsrc.json, a profile or
CDS_REQUIRES_CAP2UI5_* environment variables, like any CAP setting:
"cds": {
"requires": {
"cap2ui5": { "roles": ["admin"], "body_parser": { "limit": "20mb" } }
}
}| key | default | |
|---|---|---|
| apps | srv/apps | the directory scanned for app modules |
| roles | ["authenticated-user"] | who may call: a role, or a list of roles any one of which lets the user in, as with CAP's @requires; any or null allows anonymous callers |
| routes | /sap/bc/z2ui5, /rest/root/z2ui5 | where the roundtrip answers |
| body_parser.limit | CAP's cds.server.body_parser.limit, else 10mb | the largest roundtrip body; a larger one gets 413 |
| compression | true | gzip for the page and the roundtrips, where the browser accepts it; false leaves compressing to a proxy in front |
| accelerate | true | calls the runtime's accelerate( ) where it has one - the releases after 1.145.0, see Performance; false runs the runtime's own code |
| agent | off | the MCP endpoint for AI agents - true or { path, apps, confirm, forbidden, retention }, see Agents |
"cap2ui5": false switches the plugin off: no route, and no cap2ui5.Drafts
or cap2ui5.AgentLog table in the model.
The route runs behind CAP's own middlewares and answers like a CAP service.
Whatever cds.requires.auth is configured to identifies the user. A caller who
is not logged in gets 401 with that strategy's login challenge. A user who
lacks the role gets 403.
0.1.0 read its settings from a top-level cds.cap2ui5, with requires for the
roles. Those settings still apply, and the log names the new place.
Everything the framework decides about a response — the
Content-Security-Policy, the security headers, the UI5 bootstrap URL, the
theme, the draft expiry, the CSRF gate — comes from the user exit,
defineExit({ onPage, onRoundtrip }), one per project.
In production
- Who may call: set
rolesto the roles your identity provider grants (XSUAA scopes, IAS groups). The default,authenticated-user, is CAP's own default for production. - Behind the approuter: route the paths in
routesto the CAP backend with the approuter's authentication, like the service paths. The approuter protects routes with an X-CSRF-Token by default (csrfProtection).@abap2ui5/node-runtime1.145.0, the runtime this version pins, does not fetch that token yet, so set"csrfProtection": falseon these routes. The framework runs its own CSRF check: it comparesOrigin/Refererwith the host, and the user exit configures it. The token handshake comes with the abap2UI5 release after 1.145.0 (abap2UI5#2802). - Body size: a roundtrip carries the app's whole model.
cds.server.body_parser.limit, CAP's global limit, applies here too;body_parser.limitabove overrides it for this route. - Compression: the route gzips what it answers where the browser accepts
gzip - the page and every roundtrip of 1 kB or more - as the framework asks
the ICF to on an ABAP system. Behind an approuter or an ingress that
compresses too, nothing is compressed twice: it finds
Content-Encodingset and passes the body on."compression": falseleaves the work to it and saves the CPU here. - Logs: the plugin logs through
cds.log('cap2ui5'), so production gets JSON records with the request's correlation id. Set the level withcds.log.levels.cap2ui5. - Database:
cap2ui5.Draftsandcap2ui5.AgentLogare part of the model, socds deployandcds build --productioncreate them like any other table (.hdbtablefor SAP HANA). The agent log's table is there whether the endpoint is on or not - the database is built from the model wherecds buildruns, the endpoint is switched on where the server runs, often by an environment variable the build never sees; ADR-009 has the reasons. It is written only while the endpoint is on, and its rows are deleted afteragent.retentiondays (90). A draft is deleted after the user exit'sdraft_exp_time_in_hours, 4 hours unless the exit changes it. - Multitenancy (MTX): not tested yet. The draft store reads and writes
through
cds.run, which followscds.context. The drafts should therefore land in each tenant's database like any other row, but no test proves it.
Performance
A roundtrip carries the app's model: the framework restores it from the
draft, runs the app and answers it whole. So what a roundtrip costs grows
with the model - one editable table of n rows, measured with
npm run bench -- --rows <n> in this repository (Node 22, a shared 4-core
machine; take the ratios, not the seconds):
| rows | answer, plain / gzip | start / edit one cell, 1.145.0 | with the runtime's accelerations | |---:|---|---|---| | 500 | 45 / 6 kB | 2.7 s / 4.3 s | 0.9 s / 1.4 s | | 1000 | 90 / 11 kB | 6.0 s / 12.1 s | 0.6 s / 1.4 s | | 2000 | 181 / 21 kB | 19.6 s / 43.5 s | 1.6 s / 2.9 s | | 8000 | 727 / 82 kB | - | 3.3 s / 16.4 s |
- The runtime's accelerations. On 1.145.0 the time grows with n², not
in abap2UI5's ABAP but in two places of
@abaplint/runtime: a LOOP ... WHERE over a sorted primary key reads every row, and CP rescans the rest of the draft's XML for every token the parser reads.@abap2ui5/node-runtimefixes both withaccelerate( )from the release after 1.145.0 - the right column, measured before that release - and the plugin calls it when the server starts; the log then saysruntime accelerations active."accelerate": falseswitches them off. - Node 24 is recommended. CAP keeps
cds.contextin anAsyncLocalStorage, and on Node 22 that costs the transpiled framework - which awaits at nearly every method call - about as much time again as its own work. Node 24 keeps it in the async context frame, which costs next to nothing. On Node 22.7 and later,NODE_OPTIONS=--experimental-async-context-framedoes the same: measured back to back, it halved the times in the table, with the accelerations and without (2000 rows, an edit with them: 3.4 s, then 2.0 s). NODE_COMPILE_CACHE. The runtime is about 800 transpiled modules, and importing them takes 0.7 s on every start. WithNODE_COMPILE_CACHEset to a directory, Node keeps their compiled code there and the import takes 0.5 s - worth it forcds watch, which restarts on every save.- Compression. The page carries the whole UI5 frontend, 358 kB - 83 kB gzipped - and the route gzips it and every roundtrip of 1 kB or more where the browser accepts gzip (see In production).
Documentation
cap2ui5.github.io/docs — the guide, the API reference, the examples and the architecture, including why the exit is registered rather than discovered and where UI5 itself comes from.
Support
Bugs and questions: github.com/cap2UI5/cap2UI5/issues. What changed between versions: CHANGELOG.md.
License
MIT
