Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .claude/rules/server-middleware.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
paths:
- "packages/server/lib/middleware/**/*.js"
---

# Server middleware

Rules that apply to every middleware in `packages/server/lib/middleware/`.

## Use only the built-in Node.js HTTP API

`@ui5/server` runs on Express, but Express is an implementation detail wired up
exclusively in `packages/server/lib/server.js`. Every middleware **MUST** treat
the request and response as plain Node.js `http.IncomingMessage` /
`http.ServerResponse` objects, so the underlying HTTP framework can be swapped
without rewriting any middleware.

### MUST use — sanctioned Node.js surface

This is already the norm across the middleware directory:

- Request: `req.method`, `req.url`, `req.headers`
- Response: `res.setHeader`, `res.getHeader`, `res.writeHead`, `res.statusCode`, `res.end`

### MUST NOT use — Express-specific sugar

- Response: `res.send`, `res.json`, `res.type`, `res.status()`, `res.redirect`, `res.sendFile`, `res.cookie`
- Request: `req.query`, `req.params`, `req.cookies`, `req.get()`, `req.body`

Parse anything you need (query string, body) from the raw Node primitives
(`req.url`, the request stream) instead.

#### Exception: `req.body` after your own body-parser middleware

Reading `req.body` is fine **if you installed the body parser yourself** on your
own router — then `req.body` is the output of a middleware you control, not
Express-injected sugar. `csp.js` does this: it registers
`bodyParser.json(...)` on its router before the handler reads `req.body`.

### Model to imitate

`packages/server/lib/middleware/liveReloadClient.js` is the reference: it serves
its response using only `req.method`, `middlewareUtil.getPathname(req)`,
`res.setHeader`, `res.statusCode`, and `res.end`.
8 changes: 6 additions & 2 deletions packages/server/lib/middleware/serveResources.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,13 @@ function createMiddleware({resources, middlewareUtil, injectLiveReloadClient = f
// script runs. If a later script throws or hangs synchronously, a tag placed
// further down would never execute and the page would stay stuck — exactly
// when live reload is needed most.
res.send(injectHead(html, INJECT_SCRIPT_TAG));
const body = injectHead(html, INJECT_SCRIPT_TAG);
res.setHeader("Content-Length", Buffer.byteLength(body));
res.end(body);
} else {
res.send(await resource.getBuffer());
const body = await resource.getBuffer();
res.setHeader("Content-Length", body.length);
res.end(body);
}
} catch (err) {
next(err);
Expand Down
39 changes: 21 additions & 18 deletions packages/server/test/lib/server/middleware/serveResources.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ function createMockResponse({headers = {}} = {}) {
setHeader: sinon.stub().callsFake((name, value) => {
headers[name] = value;
}),
send: sinon.stub(),
end: sinon.stub(),
statusCode: 200,
};
Expand All @@ -52,8 +51,10 @@ test.serial("Serves resource with correct Content-Type and ETag", async (t) => {
t.regex(res.setHeader.getCall(0).args[1], /javascript/);
t.is(res.setHeader.getCall(1).args[0], "ETag");
t.is(res.setHeader.getCall(1).args[1], etag("sha256-xyz"));
t.is(res.send.callCount, 1);
t.is(res.send.getCall(0).args[0], buffer);
t.is(res.end.callCount, 1);
t.is(res.end.getCall(0).args[0], buffer);
const contentLengthCall = res.setHeader.getCalls().find((c) => c.args[0] === "Content-Length");
t.is(contentLengthCall.args[1], buffer.length);
});

test.serial("Injects liveReload tag into HTML right after <head>", async (t) => {
Expand All @@ -78,8 +79,11 @@ test.serial("Injects liveReload tag into HTML right after <head>", async (t) =>
const etagCall = res.setHeader.getCalls().find((c) => c.args[0] === "ETag");
t.truthy(etagCall);
t.is(etagCall.args[1], etag(integrity + ":" + INJECT_SCRIPT_TAG));
t.is(res.send.callCount, 1);
t.is(res.send.getCall(0).args[0], html.replace("<head>", "<head>\n" + INJECT_SCRIPT_TAG));
t.is(res.end.callCount, 1);
const expectedBody = html.replace("<head>", "<head>\n" + INJECT_SCRIPT_TAG);
t.is(res.end.getCall(0).args[0], expectedBody);
const contentLengthCall = res.setHeader.getCalls().find((c) => c.args[0] === "Content-Length");
t.is(contentLengthCall.args[1], Buffer.byteLength(expectedBody));
});

test.serial("Injects liveReload tag after <head> with attributes", async (t) => {
Expand All @@ -100,8 +104,8 @@ test.serial("Injects liveReload tag after <head> with attributes", async (t) =>

await middleware(req, res, next);

t.is(res.send.callCount, 1);
t.is(res.send.getCall(0).args[0], html.replace(`<head lang="en">`, `<head lang="en">\n` + INJECT_SCRIPT_TAG));
t.is(res.end.callCount, 1);
t.is(res.end.getCall(0).args[0], html.replace(`<head lang="en">`, `<head lang="en">\n` + INJECT_SCRIPT_TAG));
});

test.serial("Injects liveReload tag after <html> when <head> is missing", async (t) => {
Expand All @@ -122,8 +126,8 @@ test.serial("Injects liveReload tag after <html> when <head> is missing", async

await middleware(req, res, next);

t.is(res.send.callCount, 1);
t.is(res.send.getCall(0).args[0], html.replace("<html>", "<html>\n" + INJECT_SCRIPT_TAG));
t.is(res.end.callCount, 1);
t.is(res.end.getCall(0).args[0], html.replace("<html>", "<html>\n" + INJECT_SCRIPT_TAG));
});

test.serial("Prepends liveReload tag to HTML without <head> or <html>", async (t) => {
Expand All @@ -144,8 +148,8 @@ test.serial("Prepends liveReload tag to HTML without <head> or <html>", async (t

await middleware(req, res, next);

t.is(res.send.callCount, 1);
t.is(res.send.getCall(0).args[0], INJECT_SCRIPT_TAG + html);
t.is(res.end.callCount, 1);
t.is(res.end.getCall(0).args[0], INJECT_SCRIPT_TAG + html);
});

test.serial("Calls next() when resource is not found", async (t) => {
Expand All @@ -163,7 +167,7 @@ test.serial("Calls next() when resource is not found", async (t) => {

t.is(next.callCount, 1);
t.is(next.getCall(0).args.length, 0);
t.is(res.send.callCount, 0);
t.is(res.end.callCount, 0);
});

test.serial("Returns 304 Not Modified when client cache is fresh", async (t) => {
Expand All @@ -185,7 +189,7 @@ test.serial("Returns 304 Not Modified when client cache is fresh", async (t) =>

t.is(res.statusCode, 304);
t.is(res.end.callCount, 1);
t.is(res.send.callCount, 0);
t.is(res.end.getCall(0).args.length, 0);
t.is(next.callCount, 0);
});

Expand Down Expand Up @@ -223,7 +227,7 @@ test.serial("Does not override existing Content-Type header", async (t) => {

const contentTypeSetCalls = res.setHeader.getCalls().filter((c) => c.args[0] === "Content-Type");
t.is(contentTypeSetCalls.length, 0);
t.is(res.send.callCount, 1);
t.is(res.end.callCount, 1);
});

test.serial("Uses resource integrity for ETag generation", async (t) => {
Expand Down Expand Up @@ -267,7 +271,7 @@ test.serial("HTML: 304 when ETag matches injected ETag", async (t) => {

t.is(res.statusCode, 304);
t.is(res.end.callCount, 1);
t.is(res.send.callCount, 0);
t.is(res.end.getCall(0).args.length, 0);
});

test.serial("HTML: 200 when INJECT_SCRIPT_TAG changes invalidates client ETag", async (t) => {
Expand Down Expand Up @@ -298,9 +302,8 @@ test.serial("HTML: 200 when INJECT_SCRIPT_TAG changes invalidates client ETag",
await middleware(req, res, next);

t.not(res.statusCode, 304);
t.is(res.end.callCount, 0);
t.is(res.send.callCount, 1);
t.true(res.send.getCall(0).args[0].includes(newInjectTag));
t.is(res.end.callCount, 1);
t.true(res.end.getCall(0).args[0].includes(newInjectTag));
const etagCall = res.setHeader.getCalls().find((c) => c.args[0] === "ETag");
t.is(etagCall.args[1], expectedServerEtag);
});
Loading