120 lines
3.9 KiB
TypeScript
120 lines
3.9 KiB
TypeScript
// A tiny stand-in for the Wagtail CMS, reachable over HTTP so both
|
|
// `next build` and the standalone server (separate child processes) can
|
|
// query it. Serves the canned GraphQL responses from fixtures.ts, a 1x1 PNG
|
|
// for image URLs, and a state endpoint the revalidation test uses to
|
|
// "publish a change" in the CMS.
|
|
|
|
import http from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
|
|
import { makeFixtures, type Fixtures, type MockState } from "./fixtures";
|
|
|
|
// 1x1 transparent PNG
|
|
const TINY_PNG = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
|
"base64"
|
|
);
|
|
|
|
export type MockCms = {
|
|
url: string;
|
|
state: MockState;
|
|
graphqlRequests: number;
|
|
close(): Promise<void>;
|
|
};
|
|
|
|
function readBody(req: http.IncomingMessage): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on("data", (c) => chunks.push(c));
|
|
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
export async function startMockCms(): Promise<MockCms> {
|
|
const state: MockState = { newsHeadlineVersion: "v1" };
|
|
let fixtures: Fixtures | null = null; // built once we know our own URL
|
|
|
|
const mock: MockCms = {
|
|
url: "",
|
|
state,
|
|
graphqlRequests: 0,
|
|
close: () =>
|
|
new Promise((resolve, reject) =>
|
|
server.close((err) => (err ? reject(err) : resolve()))
|
|
),
|
|
};
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
try {
|
|
if (req.method === "GET" && req.url?.startsWith("/media/")) {
|
|
res.writeHead(200, {
|
|
"content-type": "image/png",
|
|
"content-length": TINY_PNG.length,
|
|
});
|
|
res.end(TINY_PNG);
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && req.url === "/__mock/state") {
|
|
Object.assign(state, JSON.parse(await readBody(req)));
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end("{}");
|
|
return;
|
|
}
|
|
|
|
if (req.url?.startsWith("/api/graphql")) {
|
|
mock.graphqlRequests += 1;
|
|
// urql sends both POST (JSON body) and GET (URL params) requests
|
|
let body: {
|
|
query?: string;
|
|
operationName?: string;
|
|
variables?: Record<string, unknown>;
|
|
};
|
|
if (req.method === "GET") {
|
|
const params = new URL(req.url, mock.url).searchParams;
|
|
body = {
|
|
query: params.get("query") ?? undefined,
|
|
operationName: params.get("operationName") ?? undefined,
|
|
variables: JSON.parse(params.get("variables") ?? "{}"),
|
|
};
|
|
} else {
|
|
body = JSON.parse(await readBody(req));
|
|
}
|
|
const name =
|
|
body.operationName ??
|
|
/(?:query|mutation)\s+(\w+)/.exec(body.query ?? "")?.[1];
|
|
const resolve = name ? fixtures?.[name] : undefined;
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
if (!resolve) {
|
|
// Loud gap detection: build-time queries throw on GraphQL errors,
|
|
// so a missing fixture fails `next build` with the name visible.
|
|
res.end(
|
|
JSON.stringify({
|
|
errors: [{ message: `mock-cms: no fixture for operation ${name}` }],
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
res.end(JSON.stringify({ data: resolve(body.variables ?? {}, state) }));
|
|
return;
|
|
}
|
|
|
|
console.error(`mock-cms: unhandled ${req.method} ${req.url}`);
|
|
res.writeHead(404);
|
|
res.end();
|
|
} catch (err) {
|
|
res.writeHead(500, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ errors: [{ message: String(err) }] }));
|
|
}
|
|
});
|
|
|
|
await new Promise<void>((resolve) =>
|
|
server.listen(0, "127.0.0.1", () => resolve())
|
|
);
|
|
const { port } = server.address() as AddressInfo;
|
|
mock.url = `http://127.0.0.1:${port}`;
|
|
fixtures = makeFixtures(mock.url);
|
|
return mock;
|
|
}
|