Skip to content
Open
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,17 @@ BASE_URL=http://localhost:3001
}
```

### Conditional read polling

Polling-friendly read endpoints emit weak `ETag` validators. Clients can send
the last value back with `If-None-Match`; unchanged responses return
`304 Not Modified` with an empty body. This is supported for
`GET /api/v1/services`, `GET /api/v1/events`, and `GET /api/v1/stats`.

`GET /api/v1/events` scopes its validator to the effective `since`, `type`, and
`limit` query values so different filters never share a validator just because
their current response bodies are both empty.

## CI/CD

On push/PR to `main`, GitHub Actions runs:
Expand Down
69 changes: 69 additions & 0 deletions src/etag-events-stats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import request from "supertest";
import { app } from "./index.js";

const uniq = (prefix: string) => `${prefix}-${Date.now()}-${Math.random()}`;

void describe("events and stats conditional GET", () => {
void it("returns an events ETag and 304 on an unchanged matching request", async () => {
const first = await request(app).get("/api/v1/events?limit=5");
assert.strictEqual(first.status, 200);
assert.ok(first.headers.etag, "events ETag header missing");

const second = await request(app)
.get("/api/v1/events?limit=5")
.set("If-None-Match", first.headers.etag as string);
assert.strictEqual(second.status, 304);
assert.strictEqual(second.text, "");
});

void it("changes the events ETag after a new event is recorded", async () => {
const first = await request(app).get("/api/v1/events?type=usage.recorded&limit=5");
assert.strictEqual(first.status, 200);
const etag = first.headers.etag as string;
assert.ok(etag, "events ETag header missing");

await request(app)
.post("/api/v1/usage")
.send({
agent: uniq("etag-agent"),
serviceId: uniq("etag-service"),
requests: 1,
})
.expect(201);

const second = await request(app).get("/api/v1/events?type=usage.recorded&limit=5");
assert.strictEqual(second.status, 200);
assert.notStrictEqual(second.headers.etag, etag);
});

void it("uses query-specific events ETags even when filtered bodies match", async () => {
const typeA = encodeURIComponent(uniq("never-a"));
const typeB = encodeURIComponent(uniq("never-b"));

const first = await request(app).get(`/api/v1/events?type=${typeA}`);
const second = await request(app).get(`/api/v1/events?type=${typeB}`);

assert.strictEqual(first.status, 200);
assert.strictEqual(second.status, 200);
assert.deepStrictEqual(first.body, { items: [] });
assert.deepStrictEqual(second.body, { items: [] });
assert.ok(first.headers.etag, "first filtered events ETag missing");
assert.ok(second.headers.etag, "second filtered events ETag missing");
assert.notStrictEqual(first.headers.etag, second.headers.etag);
});

void it("returns a stats ETag and 304 on unchanged stats", async () => {
const first = await request(app).get("/api/v1/stats");
assert.strictEqual(first.status, 200);
assert.ok(first.headers.etag, "stats ETag header missing");

const second = await request(app)
.get("/api/v1/stats")
.set("If-None-Match", first.headers.etag as string);

assert.strictEqual(second.status, 304);
assert.strictEqual(second.text, "");
});
});
16 changes: 16 additions & 0 deletions src/httpCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createHash } from "node:crypto";

/**
* Builds the weak ETag used for pollable JSON read responses.
*
* Pass the response body for body-only validators, or a small cache identity
* object when the route must distinguish identical bodies from different
* query scopes.
*/
export function etagFor(body: unknown): string {
const payload = typeof body === "string" ? body : JSON.stringify(body);
return `W/"${createHash("sha1")
.update(payload ?? "null")
.digest("base64")
.slice(0, 16)}"`;
}
14 changes: 13 additions & 1 deletion src/routes/events.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Router, type Request, type Response } from "express";
import { EVENT_LOG_CAP, eventLog } from "../events.js";
import { etagFor } from "../httpCache.js";

/**
* Builds read-only audit-event routes.
Expand All @@ -23,7 +24,18 @@ export function createEventsRouter(): Router {
const items = eventLog
.filter((e) => e.ts >= since && (type === undefined || e.type === type))
.slice(-limit);
res.json({ items });
const bodyShape = { items };
const body = JSON.stringify(bodyShape);
const etag = etagFor({
body: bodyShape,
query: { limit, since, type: type ?? null },
});
if (req.header("if-none-match") === etag) {
res.status(304).end();
return;
}
res.setHeader("ETag", etag);
res.type("application/json").send(body);
});

return router;
Expand Down
15 changes: 12 additions & 3 deletions src/routes/metrics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Router, type Response } from "express";
import { etagFor } from "../httpCache.js";
import { apiKeyStore, pauseState, servicesStore, usageStore } from "../store/state.js";

/**
Expand Down Expand Up @@ -28,20 +29,28 @@ export function createMetricsRouter(): Router {
res.send(lines.join("\n") + "\n");
});

router.get("/api/v1/stats", (_req, res: Response) => {
router.get("/api/v1/stats", (req, res: Response) => {
let totalRequests = 0;
const agents = new Set<string>();
for (const [key, total] of usageStore.entries()) {
totalRequests += total;
agents.add(key.split("::")[0]);
}
res.json({
const bodyShape = {
totalServices: servicesStore.size,
totalApiKeys: apiKeyStore.size,
totalRequests,
uniqueAgents: agents.size,
paused: pauseState.paused,
});
};
const body = JSON.stringify(bodyShape);
const etag = etagFor(body);
if (req.header("if-none-match") === etag) {
res.status(304).end();
return;
}
res.setHeader("ETag", etag);
res.type("application/json").send(body);
});

return router;
Expand Down
4 changes: 2 additions & 2 deletions src/routes/services.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { Router, type Request, type Response } from "express";
import { etagFor } from "../httpCache.js";
import {
servicesDisabled,
servicesMetadata,
Expand Down Expand Up @@ -304,7 +304,7 @@ export function createServicesRouter(): Router {
if (services.length >= limit) break;
}
const body = JSON.stringify({ services });
const etag = `W/"${createHash("sha1").update(body).digest("base64").slice(0, 16)}"`;
const etag = etagFor(body);
if (req.header("if-none-match") === etag) {
res.status(304).end();
return;
Expand Down