release: WRNexusJS 0.8.0
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# @wrnexus/graphql
|
||||
|
||||
Optional GraphQL endpoint plugin. Supply the executor from GraphQL.js, GraphQL Yoga, Mercurius,
|
||||
or another maintained engine; WRNexus owns bounded HTTP input, depth/alias limits, introspection
|
||||
policy, generic production errors and plugin route integration.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@wrnexus/graphql",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./plugin": "./src/plugin.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/plugin": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
export interface GraphqlRequest {
|
||||
query: string;
|
||||
variables?: Record<string, unknown>;
|
||||
operationName?: string;
|
||||
}
|
||||
export interface GraphqlExecutionResult {
|
||||
data?: unknown;
|
||||
errors?: Array<{ message: string; [key: string]: unknown }>;
|
||||
}
|
||||
export interface GraphqlOptions {
|
||||
execute(
|
||||
request: GraphqlRequest,
|
||||
context: { request: Request; signal: AbortSignal },
|
||||
): GraphqlExecutionResult | Promise<GraphqlExecutionResult>;
|
||||
maxQueryBytes?: number;
|
||||
maxDepth?: number;
|
||||
maxAliases?: number;
|
||||
allowIntrospection?: boolean;
|
||||
}
|
||||
|
||||
function queryMetrics(query: string) {
|
||||
let depth = 0,
|
||||
maxDepth = 0,
|
||||
aliases = 0;
|
||||
let string = false,
|
||||
escaped = false;
|
||||
for (let index = 0; index < query.length; index++) {
|
||||
const char = query[index]!;
|
||||
if (string) {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === "\\") escaped = true;
|
||||
else if (char === '"') string = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') string = true;
|
||||
else if (char === "{") maxDepth = Math.max(maxDepth, ++depth);
|
||||
else if (char === "}") depth--;
|
||||
else if (char === ":" && /[A-Za-z0-9_]\s*$/.test(query.slice(Math.max(0, index - 40), index)))
|
||||
aliases++;
|
||||
}
|
||||
return { depth: maxDepth, aliases };
|
||||
}
|
||||
|
||||
export function createGraphqlHandler(options: GraphqlOptions) {
|
||||
return async (request: Request): Promise<Response> => {
|
||||
if (request.method !== "POST")
|
||||
return new Response("Method Not Allowed", { status: 405, headers: { allow: "POST" } });
|
||||
const maxBytes = options.maxQueryBytes ?? 100_000;
|
||||
const length = Number(request.headers.get("content-length") ?? 0);
|
||||
if (length > maxBytes)
|
||||
return Response.json(
|
||||
{ errors: [{ message: "GraphQL query is too large" }] },
|
||||
{ status: 413 },
|
||||
);
|
||||
const body = (await request.json().catch(() => null)) as GraphqlRequest | null;
|
||||
if (
|
||||
!body ||
|
||||
typeof body.query !== "string" ||
|
||||
new TextEncoder().encode(body.query).length > maxBytes
|
||||
)
|
||||
return Response.json(
|
||||
{ errors: [{ message: "A bounded GraphQL query string is required" }] },
|
||||
{ status: 400 },
|
||||
);
|
||||
if (options.allowIntrospection === false && /\b__(?:schema|type)\b/.test(body.query))
|
||||
return Response.json(
|
||||
{ errors: [{ message: "GraphQL introspection is disabled" }] },
|
||||
{ status: 403 },
|
||||
);
|
||||
const metrics = queryMetrics(body.query);
|
||||
if (metrics.depth > (options.maxDepth ?? 12) || metrics.aliases > (options.maxAliases ?? 50))
|
||||
return Response.json(
|
||||
{ errors: [{ message: "GraphQL query complexity limit exceeded" }] },
|
||||
{ status: 400 },
|
||||
);
|
||||
try {
|
||||
const result = await options.execute(body, { request, signal: request.signal });
|
||||
return Response.json(result, {
|
||||
status: result.errors?.length && result.data === undefined ? 400 : 200,
|
||||
headers: { "cache-control": "no-store" },
|
||||
});
|
||||
} catch {
|
||||
return Response.json({ errors: [{ message: "GraphQL execution failed" }] }, { status: 500 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { graphqlPlugin } from "./plugin.ts";
|
||||
@@ -0,0 +1,24 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { WrnexusPlugin } from "@wrnexus/plugin";
|
||||
import { createGraphqlHandler, type GraphqlOptions } from "./index.ts";
|
||||
|
||||
export function graphqlPlugin(options: GraphqlOptions & { path?: string }): WrnexusPlugin {
|
||||
return {
|
||||
name: "@wrnexus/graphql",
|
||||
version: "0.8.0",
|
||||
setup() {
|
||||
(globalThis as any).__wrnexusGraphqlHandler = createGraphqlHandler(options);
|
||||
},
|
||||
routeEntries: [
|
||||
{
|
||||
kind: "api",
|
||||
path: options.path ?? "/api/graphql",
|
||||
entry: join(dirname(fileURLToPath(import.meta.url)), "route.ts"),
|
||||
},
|
||||
],
|
||||
documentation: ["packages/graphql/README.md"],
|
||||
typeDefinitions: ["packages/graphql/src/index.ts"],
|
||||
};
|
||||
}
|
||||
export default graphqlPlugin;
|
||||
@@ -0,0 +1,6 @@
|
||||
export async function POST(ctx: { req: Request }): Promise<Response> {
|
||||
const handler = (globalThis as any).__wrnexusGraphqlHandler;
|
||||
if (typeof handler !== "function")
|
||||
return new Response("GraphQL is not configured", { status: 503 });
|
||||
return handler(ctx.req);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createGraphqlHandler } from "../src/index.ts";
|
||||
|
||||
test("GraphQL handler enforces method, introspection and complexity limits", async () => {
|
||||
const handler = createGraphqlHandler({
|
||||
allowIntrospection: false,
|
||||
maxDepth: 2,
|
||||
execute: async () => ({ data: { ok: true } }),
|
||||
});
|
||||
expect((await handler(new Request("https://test/graphql"))).status).toBe(405);
|
||||
expect(
|
||||
(
|
||||
await handler(
|
||||
new Request("https://test/graphql", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ query: "{ __schema { types { name } } }" }),
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(
|
||||
await handler(
|
||||
new Request("https://test/graphql", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ query: "{ a { b { c } } }" }),
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
test("GraphQL handler delegates valid requests and hides thrown errors", async () => {
|
||||
const handler = createGraphqlHandler({
|
||||
execute: async (request) => ({ data: { query: request.query } }),
|
||||
});
|
||||
const response = await handler(
|
||||
new Request("https://test/graphql", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ query: "{ health }" }),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ data: { query: "{ health }" } });
|
||||
const failing = createGraphqlHandler({
|
||||
execute: async () => {
|
||||
throw new Error("secret");
|
||||
},
|
||||
});
|
||||
expect(
|
||||
await (
|
||||
await failing(
|
||||
new Request("https://test/graphql", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ query: "{ health }" }),
|
||||
}),
|
||||
)
|
||||
).text(),
|
||||
).not.toContain("secret");
|
||||
});
|
||||
Reference in New Issue
Block a user