ui5-middleware-ws-mock
v0.3.2
Published
UI5 server middleware that mocks WebSocket endpoints with optional SAP PCP v1.0 support, alongside the rest of the ui5 serve stack.
Maintainers
Readme
A UI5 custom server middleware that mocks WebSocket endpoints alongside the rest of the ui5 serve stack. It registers under customMiddleware in ui5.yaml, hooks the underlying HTTP server's upgrade event, optionally negotiates the SAP Push Channel Protocol (PCP) v10.pcp.sap.com subprotocol, and routes each WebSocket connection to a per-route handler module supplied by the consuming application.
The transport is plain WebSocket. When the client offers it, the middleware also speaks WebSocket framed under SAP's PCP v1.0 subprotocol. The middleware is transport-only beyond that wire layer: plain frames pass through as raw bytes, PCP frames are decoded into header fields and a body, and any payload semantics (JSON, base64, line-delimited records, opaque text) are entirely the handler's choice.
[!NOTE] Much of this repository was authored hands-off, through speech-to-text dictation paired with AI coding assistance, during post-surgery recovery with only one hand. That said, the majority of the implementation is grounded in pre-existing patterns from the UI5 ecosystem (notably Peter Muessig's work credited below) and conventional Node / WebSocket / PCP techniques that were ported, refactored, and hardened. It was partially "vibe-coded". There can be hallucinations missed during review or simple consumption-side bugs; no software is perfect. Feel free to open an issue or a PR and fix them directly.
What it does
- Listens for HTTP upgrade requests on the paths declared in
ui5.yaml. - Negotiates the PCP v1.0 subprotocol when the client offers it; otherwise runs in plain WebSocket mode. Handlers see
ctx.modeand branch on it where needed. - Loads one handler module per route at startup (TypeScript or JavaScript).
- Forwards every inbound frame to the handler's
onMessage. In plain mode the handler receives the raw frame string; in PCP mode it receives a decoded{ fields, body }object with the wire bytes preserved verbatim. - Provides
ctx.send(message)for outbound writes. Plain mode writes the string verbatim. PCP mode accepts either a string (wrapped in a defaultpcp-action:MESSAGE/pcp-body-type:textframe) or anEncodeOptionsobject (the middleware callsencode()internally). The TypeScript surface narrows onctx.modeso theEncodeOptionsoverload is only offered to PCP-mode call sites. For framings the public encoder does not cover, fall back toctx.ws.sendwith a pre-built wire string. - Logs handler failures, malformed frames, and non-open-socket sends without crashing
ui5 serveor the connection.
What it does not do
- Does not expose an arbitrary Express middleware. HTTP-level middleware should be registered as a separate
customMiddlewareentry. - Does not support multiple handler modules per route. Each route resolves to exactly one handler file. Composition belongs inside the handler module.
- Does not support dynamic or parametrized mount paths.
mountPathis matched against the request pathname literally. UI5-style route patterns with optional or mandatory parameters ({param},:optional?, etc.) are not supported. Per-resource routing should be derived fromreq.urlinside the handler. - Does not persist state across server restarts.
- Does not impose a payload contract. Named-message dispatch ("action routing"), JSON envelopes, and any other application-level convention are the handler's responsibility; the middleware ships nothing of the kind.
- Does not proxy to a real backend. For proxying, use
ui5-middleware-simpleproxyorfiori-tools-proxy.
Prerequisites
- Node.js ≥ 22.18 (declared in
engines; required for the native TypeScript type stripping the handler loader relies on when handlers are authored in TS). @ui5/cli≥ 4.0.0 (this middleware declaresspecVersion: "4.0"; older CLI versions reject the extension).- A UI5 project of
kind: project,type: application/library/themeLibrary.Module-type projects needconfiguration.rootPathbecause they have no single source path. - TypeScript is not required to use the middleware; handlers may be plain
.jsfiles. If you write handlers in TypeScript, Node ≥ 22.18 runs them directly via native type stripping; nots-nodestep is needed.
Version compatibility
| ui5-middleware-ws-mock | UI5 Tooling specVersion | @ui5/cli | Node | TypeScript (optional) |
| ------------------------ | ----------------------- | ---------- | --------- | --------------------- |
| 0.x | 4.0 | ≥ 4.0.0 | ≥ 22.18 | ~ 6.0 |
Pre-1.0 the public types and the middleware configuration shape may change in minor releases. Note that for 0.x versions npm semver treats the minor as the major: ^0.3.0 and ~0.3.0 resolve to the same range (>=0.3.0 <0.4.0), so either form pins to the current minor.
Quick start
Install:
npm install --save-dev ui5-middleware-ws-mockRegister under
server.customMiddlewareinui5.yaml:server: customMiddleware: - name: ui5-middleware-ws-mock afterMiddleware: compression configuration: routes: - mountPath: /ws/foo handler: wsmock/handlers/foo.tsClients connect to
ws://<host>:<port><mountPath>.handlerresolves under the UI5 project's source path (typicallywebapp/), so the example above loads<project>/webapp/wsmock/handlers/foo.ts. For handlers outside the source folder, setconfiguration.rootPath(see Configuration).The middleware's
kind: extensiondeclaration ships in the package and is auto-discovered by@ui5/server; no separate extension file is required in the consuming application.Write a handler module at
webapp/wsmock/handlers/foo.ts.default-export aWebSocketHandler:import type { WebSocketHandler } from "ui5-middleware-ws-mock"; const handler: WebSocketHandler = { onConnect: (ctx) => ctx.send("HELLO"), onMessage: (ctx, message) => { const body = typeof message === "string" ? message : message.body; ctx.send(`echo:${body}`); }, onClose: (ctx, code) => ctx.log.info(`close ${code}`), }; export default handler;The handler runs in the
ui5 serveNode process.messageis astringin plain mode and{ fields, body }in PCP mode; the handler narrows ontypeof(or onctx.mode).Run
npm start. The server log prints one pair of lines per configured route:[ws-mock] resolving handler paths against /abs/path/to/project/webapp [ws-mock:/ws/foo] handler loaded from wsmock/handlers/foo.ts (/abs/path/to/project/webapp/wsmock/handlers/foo.ts) [ws-mock] listening for upgrades on: /ws/fooThe first line is logged at
verboseand shows the effective root path; the per-route line also includes the absolute resolved path, so a handler load failure points directly at the file the middleware tried to import.
[!TIP] Handlers and
ui5.yamledits require aui5 serverestart; livereload only coverswebapp/-side code. See Limitations for the supervisor pattern that automates it.
Configuration
The configuration block under the customMiddleware entry accepts:
| Key | Type | Required | Description |
| -------------------- | ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| rootPath | string | no | Override the root directory that routes[].handler paths resolve against. Resolved relative to the project root (the directory containing ui5.yaml); absolute paths are honored as-is. Defaults to the UI5 project's source path: webapp/ for Application projects, src/ for Library/ThemeLibrary projects (honoring any overrides under resources.configuration.paths). Module-type projects have no single source path, so rootPath is required there. |
| routes | WebSocketRoute[] | yes | One entry per mount path. Each entry declares a path and the file that provides the handler module. |
| routes[].mountPath | string | yes | Path such as /ws/foo. Matched against the upgrade request pathname literally; no parameter patterns. Clients connect to ws://<host>:<port><mountPath>. |
| routes[].handler | string | yes | Path to the handler module, resolved against the effective root (see rootPath above). Absolute paths are honored as-is. Exactly one handler per route. |
rootPath resolution matrix
The same handler value resolves to a different file depending on rootPath. All four forms are legal; pick the one that fits your project layout. Assume a project rooted at <project>/ (the directory containing ui5.yaml).
Default (no rootPath). Handler paths resolve under the UI5 source path: <project>/webapp/ for Application projects, <project>/src/ for Library/ThemeLibrary. Best fit for handlers that ship inside the deployed app:
configuration:
routes:
- mountPath: /ws/foo
handler: wsmock/handlers/foo.ts
# loads <project>/webapp/wsmock/handlers/foo.tsrootPath: ".". Resolves from the project root (where ui5.yaml lives). Best fit when handlers live alongside ui5.yaml, not inside webapp/:
configuration:
rootPath: "."
routes:
- mountPath: /ws/foo
handler: wsmock/handlers/foo.ts
# loads <project>/wsmock/handlers/foo.tsRelative rootPath. Resolves under a subdirectory of the project root. Best fit for keeping mocks alongside other test artifacts:
configuration:
rootPath: test/wsmock
routes:
- mountPath: /ws/foo
handler: handlers/foo.ts
# loads <project>/test/wsmock/handlers/foo.tsAbsolute rootPath. Resolves verbatim, ignoring the project root. Best fit for handler bundles shared across multiple apps in a monorepo:
configuration:
rootPath: /shared/wsmocks
routes:
- mountPath: /ws/foo
handler: foo.ts
# loads /shared/wsmocks/foo.tsOn Windows, quote absolute paths to keep YAML happy: rootPath: "C:/shared/wsmocks" (forward slashes work fine; backslashes need to be doubled or the string quoted).
Multiple routes
Multiple routes in the same middleware entry share the resolved root. Each loads its own handler:
configuration:
routes:
- mountPath: /ws/foo
handler: wsmock/handlers/foo.ts
- mountPath: /ws/bar
handler: wsmock/handlers/bar.tsWire layer: WebSocket and PCP
The middleware speaks WebSocket. When the connecting client offers the v10.pcp.sap.com subprotocol, the middleware also speaks PCP framing. That is the entire wire-level contract.
- Plain WebSocket. A frame is whatever bytes the peers exchanged. The middleware imposes no specific shape;
onMessagereceives the raw frame string. - PCP. Frames are split into header fields and a body, per the SAP PCP v1.0 spec. Negotiation happens once per connection at the handshake; every frame on the connection is PCP-framed in both directions thereafter.
onMessagereceives a decoded{ fields, body }object with the body bytes preserved verbatim.
Either mode can carry any payload format (JSON objects, opaque text, base64-encoded bytes, line-delimited records, etc.). The middleware never JSON-parses the body, never wraps outbound payloads in an envelope, and never invents routing keys; whatever framing or encoding the peers agree on lives entirely in handler code.
The PCP v1.0 codec is implemented in src/pcp.ts and re-exported from the package root as encode / decode / pcpEscape / pcpUnescape / SUBPROTOCOL; the ws package itself has no PCP awareness. Handlers should prefer ctx.send (which calls encode internally) for outbound writes from inside a handler callback; the standalone encode / decode exports are intended for code that does not have a WebSocketContext to hand (fixtures, test harnesses, fan-out workers that hold only a raw WebSocket, etc.).
Negotiation
Negotiation is per connection, not per route. The same mountPath can serve a PCP client and a plain client at the same time; each connection negotiates independently at its own handshake, and the handler reads the outcome through that connection's ctx.mode.
The middleware constructs its WebSocketServer with:
handleProtocols: (protocols) => (protocols.has("v10.pcp.sap.com") ? "v10.pcp.sap.com" : false);Clients that offer v10.pcp.sap.com get it echoed back and run in PCP mode; encoding and decoding go through the codec in src/pcp.ts. Clients that offer no subprotocol (plain WebSocket) get no subprotocol back and run in plain mode. Clients that offer only some other subprotocol fail their own handshake per RFC 6455 §4.2.2, because no echo is returned for unrecognized subprotocols.
After the handshake, ws.protocol is either "v10.pcp.sap.com" or "", and the middleware snapshots that value into ctx.mode. The mode is fixed for the lifetime of that connection. If a route should only ever serve one mode, enforce it with a guard inside the handler. See Asserting a single mode.
Backpressure and payload limits
The middleware does not throttle or buffer-cap. Two ws-level defaults are worth knowing about when a handler pushes high-frequency frames or accepts large inbound payloads:
Backpressure. A slow or stalled peer cannot acknowledge frames as fast as the handler produces them; the unsent bytes accumulate in
ws.bufferedAmount. Handlers that periodically push (setInterval, change-detection loops, fan-out subscriber sets) should samplectx.ws.bufferedAmountand skip a tick when it grows past a threshold:onConnect: (ctx) => { const timer = setInterval(() => { if (ctx.ws.bufferedAmount > 1_000_000) return; // ~1 MiB unsent ctx.send(`tick at ${Date.now()}`); }, 100); ctx.ws.on("close", () => clearInterval(timer)); };maxPayloaddefault. Inbound frames larger thanws's defaultmaxPayload(100 MiB) are rejected at the WebSocket layer before reachingonMessage; the connection is closed with code 1009 (Message Too Big). The middleware does not expose a knob to override this. For mock scenarios this ceiling is almost always far above realistic test payloads.
Handler API
A handler module default-exports an object implementing WebSocketHandler (defined in src/types.ts):
export interface WebSocketHandler {
onConnect?: (ctx: WebSocketContext) => void | Promise<void>;
onMessage?: (ctx: WebSocketContext, message: InboundMessage) => void | Promise<void>;
onClose?: (ctx: WebSocketContext, code: number, reason: string) => void | Promise<void>;
onError?: (ctx: WebSocketContext, err: unknown) => void | Promise<void>;
}
export type InboundMessage = string | PcpFrame;
export interface PcpFrame {
fields: Record<string, string>; // includes pcp-action, pcp-body-type
body: string; // raw body bytes as utf-8
}All callbacks are optional. A handler that only implements onMessage is valid; so is a handler that only implements onConnect (e.g. a periodic-push fixture that never reads inbound traffic). Frames that arrive when no onMessage is defined are dropped with a verbose log. Any callback may be async; the middleware awaits returned promises and logs rejections through ctx.log.error without closing the connection.
onError fires whenever the middleware catches an error from this connection: a sync throw or async rejection from any other callback, an encode() failure raised inside ctx.send (PCP mode), or a 'error' event on the underlying ws socket. The error is always logged first; the hook is an additional notification, not a replacement. A throw from onError itself is logged once and does not re-enter the hook.
WebSocketContext
WebSocketContext is a discriminated union on mode (defined in src/types.ts). Every callback receives one of the two members (PlainWebSocketContext or PcpWebSocketContext, both re-exported from the package root). TypeScript narrows the union on ctx.mode === "pcp" / "plain", which unlocks the appropriate send signature:
| Field | Type | Description |
| ----------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| ws | WebSocket | Raw ws instance. Required for any framing the helper methods do not cover. |
| req | http.IncomingMessage | The HTTP upgrade request. Useful for url, headers, socket.remoteAddress. |
| mode | "pcp" \| "plain" | Negotiated at the handshake; fixed for the lifetime of the connection. Discriminant for the union; narrow on it to interpret message and choose send. |
| log | WebSocketLog | Scoped logger prefixed with [ws-mock:<mountPath>]. Methods mirror @ui5/logger's level names: silly, verbose, perf, info, warn, error. |
| send | plain: (message: string) => voidpcp: (message: string \| EncodeOptions) => void | Send a frame. Plain mode writes the bytes through ws.send unchanged. PCP mode accepts a string (wrapped in a default frame) or EncodeOptions. See below. |
| close | (code?, reason?) => void | Close the connection with optional code (default 1000) and reason. |
| terminate | () => void | Hard-kill the socket without a close handshake. The client observes code 1006. |
Calling ctx.send("text") is legal in either branch because string is in both signatures, so call sites that do not need PCP-specific framing do not need to narrow first. Calling ctx.send({ action: "...", body: "..." }) requires the PCP narrow.
Consumers can filter logs by the [ws-mock:<mountPath>] prefix or by log message content.
ctx.send(message)
The string overload behaves the same way in both modes; the EncodeOptions overload is PCP-only.
ctx.send("HELLO");
if (ctx.mode === "pcp") {
ctx.send({
action: "EVENT",
bodyType: "text",
fields: { correlationId: "abc" },
body: "payload",
});
}The middleware does not interpret the bytes:
- Plain mode.
messageis written throughws.sendunchanged. - PCP mode, string.
messageis wrapped in a default PCP frame (pcp-action:MESSAGE,pcp-body-type:text, no extra header fields) withmessageas the body. - PCP mode,
EncodeOptions. The middleware callsencode(message)internally and writes the resulting wire string.EncodeOptionsis re-exported from the package root.
ctx.send never throws; failure cases (closed socket, ws.send throw, encode() throw on an empty PCP field name) are summarized in Error handling.
For binary payloads, base64-encode the bytes and pass bodyType: "binary":
if (ctx.mode === "pcp") {
ctx.send({ bodyType: "binary", body: someBuffer.toString("base64") });
}For framing the public encoder cannot express (alternate separator handling, raw non-PCP wire formats, etc.), fall back to ctx.ws.send with a pre-built wire string. encode is re-exported from the package root for that purpose.
Asserting a single mode
When a route is single-mode by contract (a PCP-only endpoint where any plain client is a bug, for instance), narrowing on ctx.mode at every call site adds noise. Two type-safe patterns let you skip the per-call narrow. Both rely on the named branches of the discriminated union (PlainWebSocketContext / PcpWebSocketContext), which are re-exported from the package root alongside WebSocketContext.
Early-return narrow (recommended). A single guard at the top of the callback rejects a wrong-mode client and narrows ctx for the rest of the function body. Closing the connection is the loud part; a bare throw would only log under the handler-invocation wrapper and leave the wrong-mode client connected:
import type { WebSocketHandler } from "ui5-middleware-ws-mock";
const handler: WebSocketHandler = {
onConnect: (ctx) => {
if (ctx.mode !== "pcp") {
ctx.log.warn(`rejecting non-PCP client (mode=${ctx.mode})`);
ctx.close(1008, "route requires PCP subprotocol"); // 1008 = Policy Violation
return;
}
// ctx is narrowed to PcpWebSocketContext for the rest of the body.
ctx.send({ action: "HELLO", body: "" });
},
};
export default handler;If the same assumption recurs across handlers, factor it into a TypeScript asserts helper. The predicate gives the same compile-time narrowing as the inline if/return (the runtime close is then the caller's responsibility, or the helper can call ctx.close and throw so the wrapper logs once before the connection drops):
import type {
PcpWebSocketContext,
WebSocketContext,
WebSocketHandler,
} from "ui5-middleware-ws-mock";
function assertPcp(ctx: WebSocketContext): asserts ctx is PcpWebSocketContext {
if (ctx.mode !== "pcp") {
ctx.close(1008, "route requires PCP subprotocol");
throw new Error(`expected PCP route, got mode=${ctx.mode}`);
}
}
const handler: WebSocketHandler = {
onConnect: (ctx) => {
assertPcp(ctx);
ctx.send({ action: "HELLO", body: "" });
},
};
export default handler;Direct parameter narrowing (onConnect: (ctx: PcpWebSocketContext) => …) is rejected by TypeScript; see Troubleshooting for the exact error and why. A ctx as PcpWebSocketContext cast is not recommended either: it strips the runtime check the early-return pattern gives you. On a misnegotiated (plain-mode) connection the cast lets a handler call ctx.send({ action, body }), which plain mode's (message: string) => void receives as a non-string. ws.send then transmits the object's stringified form (typically [object Object]), so the peer sees a malformed frame rather than a clean negotiation failure.
Inbound message
InboundMessage is string | PcpFrame:
- Plain mode.
messageis the raw frame body as it arrived on the wire. - PCP mode.
messageis{ fields, body }.fieldsincludespcp-action,pcp-body-type, and every application-defined header field.bodyis the body bytes as a UTF-8 string with no JSON parsing or other interpretation.
Handlers narrow with typeof message === "string" (or ctx.mode === "plain") before reading.
Writing handlers for custom scenarios
Custom logic lives entirely inside the callbacks the handler provides.
Named-message dispatch ("action routing") in user-land
The middleware does not ship action routing. If you want a name → callback map, build it in two lines on top of onMessage:
import type { WebSocketHandler, WebSocketContext } from "ui5-middleware-ws-mock";
type Action = (ctx: WebSocketContext, body: string) => void;
const actions: Record<string, Action> = {
PING: (ctx, body) => reply(ctx, "PONG", body),
BAR: (ctx, body) => reply(ctx, "BAR_REPLY", body),
};
function reply(ctx: WebSocketContext, action: string, body: string): void {
if (ctx.mode === "pcp") {
ctx.send({ fields: { action }, body });
} else {
ctx.send(`${action}:${body}`); // pick whichever plain-mode framing your client speaks
}
}
const handler: WebSocketHandler = {
onMessage: (ctx, message) => {
let action: string | undefined;
let body: string;
if (typeof message === "string") {
const idx = message.indexOf(":");
action = idx >= 0 ? message.slice(0, idx) : message;
body = idx >= 0 ? message.slice(idx + 1) : "";
} else {
action = message.fields.action;
body = message.body;
}
const fn = action ? actions[action] : undefined;
if (fn) fn(ctx, body);
else ctx.log.verbose(`unhandled action=${action ?? "(none)"}`);
},
};
export default handler;The plain-mode wire shape (ACTION:body here) is whatever your client speaks; pick to match.
Stateful per-connection handlers
Per-connection state belongs in a WeakMap keyed by ctx; the entry is collected automatically when the connection ends.
const state = new WeakMap<WebSocketContext, { count: number }>();
const handler: WebSocketHandler = {
onConnect: (ctx) => {
state.set(ctx, { count: 0 });
ctx.send("HELLO");
},
onMessage: (ctx) => {
const s = state.get(ctx);
if (!s) return;
s.count += 1;
ctx.send(`count=${s.count}`);
},
};Shared state across connections
const subscribers = new Set<WebSocketContext>();
const handler: WebSocketHandler = {
onConnect: (ctx) => subscribers.add(ctx),
onClose: (ctx) => subscribers.delete(ctx),
onMessage: (_ctx, message) => {
const body = typeof message === "string" ? message : message.body;
for (const sub of subscribers) sub.send(`event:${body}`);
},
};Periodic push
const handler: WebSocketHandler = {
onConnect: (ctx) => {
const timer = setInterval(() => {
ctx.send(`tick at ${Date.now()}`);
}, 1000);
ctx.ws.on("close", () => clearInterval(timer));
},
};Simulating backend latency
const handler: WebSocketHandler = {
onMessage: async (ctx, message) => {
await new Promise((r) => setTimeout(r, 500));
const body = typeof message === "string" ? message : message.body;
ctx.send(`ack:${body}`);
},
};Forcing disconnects for retry-strategy testing
const handler: WebSocketHandler = {
onMessage: (ctx, message) => {
const body = typeof message === "string" ? message : message.body;
if (body === "DISCONNECT") return ctx.close(1001, "requested"); // clean close
if (body === "TERMINATE") return ctx.terminate(); // abrupt; client sees 1006
},
};Error handling
Every failure site is caught and logged through the route-scoped logger ([ws-mock:<mountPath>]). The connection stays open unless the handler explicitly closes it.
| Site | Policy |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ws.send on a non-open socket | pre-check ws.readyState === OPEN; skip with a warn when not. |
| ws.send throws synchronously | caught around the call; log at error; connection is left to close via ws's own error handling. |
| ws-level 'error' event | always-on listener attached at the top of attachConnection; log at error. Required to keep a misbehaving peer (invalid UTF-8, oversize payload, malformed frame) from crashing ui5 serve via Node's EventEmitter contract. |
| Malformed inbound PCP frame | decoder returns partial data; onMessage sees best-effort fields / body (empty fields if the LFLF separator is missing, mirroring SapPcpWebSocket). The fallback also logs at verbose so it's distinguishable from an empty-headers frame. |
| Handler sync throw | caught; log at error; connection stays open. |
| Handler async rejection | .catch(err => ctx.log.error(...)); connection stays open. |
| Dynamic import(handler) failure at startup | logged at error; the route accepts the upgrade then closes with code 1011 (Internal Server Error). |
| Unparseable upgrade URL | try { new URL(req.url, ...) } catch bails without claiming the upgrade so other listeners get a shot; log at verbose. |
ctx.send does not wrap encode() in a try/catch. The string overload cannot trigger encode's only error condition (empty field name). The EncodeOptions overload can, but the throw belongs to the caller's mistake (an empty key in fields); handlers that pass user-controlled field names are responsible for guarding against it.
How it works under the hood
UI5 tooling's custom-middleware API does not expose the underlying HTTP server, which is required to hook the upgrade event. The middleware works around that using the ui5-utils-express/lib/hook helper from the UI5 ecosystem showcase.
The three phases of startup
ui5 serve walks a distinct sequence traceable in the @ui5/server source.
Phase 1: factory invocation (at startup).
@ui5/serverreadsui5.yaml, finds thecustomMiddlewareentry and the matchingkind: extension, type: server-middlewaredeclaration.- It performs
await import(pathToFileURL(middleware.path))and resolves thedefaultexport, which is thewsMockfactory (an async function). - It calls
await factory({ log, options, middlewareUtil, resources }). The factory readsoptions.configuration.routes, loads each handler module via dynamicimport(), and returns the result ofhook(...).
Phase 2: mount (before the server listens).
@ui5/serverregisters the returned function in the Express app viaapp.use(mountPath, fn).- Express sees that
fnexposeshandle,set, andemitmethods and treats it as a sub-app. It firesfn.emit("mount", app)to notify the sub-app of its parent. - The
emitimplementation inside thehookhelper captures the parentapp, records the currentapp._router.stack.length(middleware position), and monkey-patchesapp.listen.
Phase 3: listen (server start).
@ui5/servercallsapp.listen(port, host, cb).- The patched
listenruns the original (which creates and starts the HTTP server), captures the returnedserver, then invokes the registered callback with{ app, server, on, use, options }. - Inside the callback the middleware constructs its
WebSocketServerwith{ noServer: true, handleProtocols: ... }and registersserver.on("upgrade", ...).
From that point on, every incoming upgrade request flows through the middleware's listener; matching pathnames are dispatched via wss.handleUpgrade(req, socket, head, cb), and non-matching requests return silently so other upgrade listeners (for example ui5-middleware-livereload's WS channel) see the event next.
Why the workaround is necessary
@ui5/server's MiddlewareUtil exposes getPathname, getMimeInfo, getProject, getDependencies, and resourceFactory. There is no getServer() or getApp(). The only path to the underlying HTTP server from inside a custom middleware is to observe Express's startup events directly.
Coexistence safety
Two independent concerns:
HTTP middleware chain order. Determined by afterMiddleware: / beforeMiddleware: declarations. The hook helper respects this: it captures the middleware position at mount time and re-slots any middleware the consumer adds via its use callback. The middleware itself does not contribute HTTP-level middleware; the inner function is a pass-through (next()), so the HTTP request chain is untouched.
Upgrade event coexistence. Node's http.Server is an EventEmitter; multiple listeners attach to "upgrade" and each one decides whether the request belongs to it. The safe pattern is:
- Inspect
req.urlto determine whether the request matches a configured route. - If yes, call
wss.handleUpgrade(req, socket, head, cb). - If no, return without performing any side effect. Do not log, destroy the socket, or write a response. Other upgrade listeners run next.
The middleware follows this pattern. Other libraries that hook "upgrade" on the same server coexist without interference.
The tricks, named
- Mount event capture. Express's public API fires
"mount"on a sub-app as part ofapp.use(subApp). Any object withhandle,set, andemitmethods is accepted as a sub-app. The hook returns exactly that shape and receives the parentappreference for free. app.listenmonkey-patching. The hook replacesapp.listenwith a wrapper that calls the original, captures the returned server, and fires a callback. Necessary because no event for "server is about to listen" reaches a sub-app.WebSocketServer({ noServer: true }). Tellswsnot to attach itself to any HTTP server; handshakes are fed in manually viahandleUpgrade. MultipleWebSocketServerinstances on the same HTTP server can coexist this way.
Tradeoff vs. a standalone Node WebSocket server
An alternative architecture is a standalone Node process listening on a separate port, with the UI served over the regular ui5 serve. The integrated approach trades:
- Same-origin. The WebSocket endpoint is served from the same host and port as the UI. No CORS issues, no cross-origin cookies, no second process to manage.
- Shared lifecycle.
npm startboots everything; Ctrl-C stops everything. A standalone server requires separate orchestration. - Proxy-friendliness. Anything that proxies to
ui5 serve(BTP destinations, reverse proxies, Fiori Tools Preview) automatically covers the WS endpoint.
The cost is the coupling to the hook trick. A future UI5 tooling major bump that breaks the hook breaks this middleware with it; a standalone-server alternative would not be affected. For local development mocks, that is an acceptable tradeoff.
Limitations
- Handler modules are imported once at server start. Each route's handler is loaded via dynamic
import()duringui5 servestartup; the module is then cached in Node's ESM loader for the process lifetime. Picking up handler edits requires aui5 serverestart. A process supervisor such astsx watchornodemon --watch <handlers-dir>automates this. Adding the handler directory toui5-middleware-livereload'swatchPathreloads the browser when files change but does not restart the server. - One handler module per route. No chaining or composition is performed by the middleware. Layered behavior should be composed inside the handler module.
- No dynamic / parametrized mount paths.
mountPathis matched literally against the incoming request pathname. UI5-style route patterns with optional or mandatory parameters are not supported. Per-resource routing should be derived fromreq.url(query string or path segments) inside the handler. - Requires specVersion 3.0+ on the middleware extension to use
middlewareUtil.getProject().getRootPath()(andgetSourcePath()) for resolving handler paths. This middleware declaresspecVersion: "4.0".
Troubleshooting
A handler edit didn't take effect. Handler modules are imported once at server start and cached for the process lifetime. Stop ui5 serve and start it again, or run it under a supervisor (tsx watch, nodemon --watch <handlers-dir>) that restarts the whole process on changes. See Limitations.
The client disconnects with code 1011. The handler module failed to load (syntax error, missing default export, import that threw). The middleware accepts the upgrade then closes with 1011 Internal Server Error; the failure is also logged at server start with the absolute file path the middleware tried to import. Fix the module and restart the server.
The client disconnects with code 1006. This is the "no close frame received" code, emitted by the client when the TCP connection drops without a clean WebSocket close. Most often: the server process exited (handler throw that wasn't caught; almost everything inside the middleware is caught, but raw ctx.ws.on(...) listeners on the underlying socket are the handler's own to guard), or ctx.terminate() was called.
The client offers a subprotocol and the handshake fails. Only v10.pcp.sap.com is recognized. Any other offered subprotocol receives no echo from the server; per RFC 6455 §4.2.2 the client fails its own handshake. Plain WebSocket clients that offer no subprotocol succeed and run in plain mode.
Custom pcp-XXX header disappears in PCP mode. pcp-* is a reserved prefix in the PCP spec. The encoder silently drops pcp-* keys from EncodeOptions.fields; the two reserved fields go through the dedicated action and bodyType options instead. Application-defined header names should not start with pcp-.
Types of parameters 'ctx' and 'ctx' are incompatible. A handler typed as onConnect: (ctx: PcpWebSocketContext) => … will not assign to WebSocketHandler. TypeScript checks function-property parameters contravariantly under strictFunctionTypes, so a callback that only accepts PcpWebSocketContext is structurally incompatible with the middleware's contract of invoking your handler with whichever mode the connection negotiated. Use the early-return narrow or asserts helper documented under Asserting a single mode.
TypeScript can't find this package's types from my consuming project. The package is published ESM-only: dist/index.d.ts is exposed under the types/default export conditions only, with no require condition and no .d.cts shadow. In a CommonJS resolution context ("module": "commonjs"/"node10", or "node16"/"nodenext" with "type": "commonjs" or none in the importer's nearest package.json), a plain import type does not resolve. Three workarounds:
- Per-import override.
import type { … } from "ui5-middleware-ws-mock" with { "resolution-mode": "import" };tellstscto resolve the specifier as if the importing file were ESM. - ESM the consuming project. Set
"type": "module"in itspackage.jsonand"module": "nodenext"/"moduleResolution": "nodenext"in itstsconfig.json. - Bundler-mode resolution. Set
"moduleResolution": "bundler"when a bundler (Vite, esbuild, etc.) loads modules.
This package does not ship a .d.cts shadow; the ecosystem is moving to ESM and a CJS resolution context is not a goal here.
Related
- Specification of the Push Channel Protocol (PCP): SAP's PCP v1.0 wire-format spec that
src/pcp.tsimplements. ui5-utils-express/lib/hook.js: the helper used to obtain the underlying HTTP server.ui5-lib-misc-util§ websocket: an example UI5 client that talks to this middleware. The middleware is transport-agnostic and works with any WebSocket client; this is one such client.
Contributing
Issues and pull requests are welcome. Anything goes: bug reports, feature ideas, questions about the design, or notes from using the middleware in a real project. For larger changes, a quick issue first to sketch the approach avoids wasted work.
Commit messages follow Conventional Commits (enforced via commitlint). Before submitting a PR, run npm run check to cover formatting, linting, and type-checks, and npm test for the vitest suite.
License
MIT. See LICENSE.
Credits
[!NOTE] The pattern of hosting a WebSocket endpoint alongside
ui5 serve, and the technique used to obtain the underlying HTTP server from inside the UI5 tooling lifecycle, originate from prior work by Peter Muessig in the UI5 community ecosystem. This package adds a per-route mock dispatch model with PCP negotiation on top of that foundation.
ui5-middleware-websocketby Peter Muessig: generic WebSocket transport forui5 serve. Different scope from this package (no per-route handler dispatch, no PCP framing); the original demonstration that hosting a WebSocket endpoint insideui5 serveis feasible.ui5-utils-expressby Peter Muessig: thelib/hookhelper this middleware depends on to obtain the underlying HTTP server from inside a UI5 custom middleware factory.
