Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
# @suaveplan/api-graphql-ui-graphiql — GraphiQL UI mount for the Suaveplan API plugin system. Exposes createGraphiqlUi() — a factory returning a RouterPlugin that registers GET /graphql/ui (or any configured endpoint) and serves a self-contained HTML shell + bundled assets with a strict CSP. Default GraphQL explorer when operators want GraphiQL; sibling api-graphql-ui-altair covers the Altair alternative.
GraphiQL UI mount for the Suaveplan API plugin system. Stage 7 of
the umbrella plan.
`createGraphiqlUi(opts)` returns a `RouterPlugin` that registers
`GET /graphql/ui` (or any configured endpoint) on a host
`@suaveplan/api-router` `Router`. The endpoint serves a self-contained
HTML shell plus bundled JS/CSS assets — no CDN fetches.
<!-- begin:auto-docs -->
## Documentation tree
- [assets](./src/assets/assets.md) — Bundled static-asset registry for the GraphiQL UI plugin. The HTML shell references two assets (`graphiql.js`, `graphiq…
- [assets](./src/assets/assets.md) — Bundled static-asset registry for the GraphiQL UI plugin. The HTML shell references two assets (`graphiql.js`, `graphiq…
- [error-codes](./src/error-codes/error-codes.md) — Owns the stable `GRAPHQL_UI_GRAPHIQL.*` error-code registry consumed by the GraphiQL UI plugin. Every code in the regis…
- [error-codes](./src/error-codes/error-codes.md) — Owns the stable `GRAPHQL_UI_GRAPHIQL.*` error-code registry consumed by the GraphiQL UI plugin. Every code in the regis…
- [factory](./src/factory/create-graphiql-ui.md) — Public entry point of the package. `createGraphiqlUi(opts)` returns a `RouterPlugin` — a plain value with `name`, `vers…
- [create-graphiql-ui](./src/factory/create-graphiql-ui.md) — Public entry point of the package. `createGraphiqlUi(opts)` returns a `RouterPlugin` — a plain value with `name`, `vers…
- [headers](./src/headers/csp.md) — Owns the `Content-Security-Policy` header values emitted on the GraphiQL HTML response. The policy is hard-coded to a p…
- [csp](./src/headers/csp.md) — Owns the `Content-Security-Policy` header values emitted on the GraphiQL HTML response. The policy is hard-coded to a p…
- [html](./src/html/render-shell.md) — Renders the GraphiQL HTML shell. The output embeds the configured GraphQL endpoint, subscription endpoint, theme, and e…
- [render-shell](./src/html/render-shell.md) — Renders the GraphiQL HTML shell. The output embeds the configured GraphQL endpoint, subscription endpoint, theme, and e…
- [instruments](./src/instruments/instruments.md) — Owns the OpenTelemetry instrument bundle emitted by the GraphiQL UI plugin. A single `Counter` (`graphql.ui.requests_to…
- [instruments](./src/instruments/instruments.md) — Owns the OpenTelemetry instrument bundle emitted by the GraphiQL UI plugin. A single `Counter` (`graphql.ui.requests_to…
- [options](./src/options/options.md) — Zod-validated configuration surface for `createGraphiqlUi`. Owns `GraphiqlUiOptions` (resolved shape with every default…
- [options](./src/options/options.md) — Zod-validated configuration surface for `createGraphiqlUi`. Owns `GraphiqlUiOptions` (resolved shape with every default…
<!-- end:auto-docs -->
## Why opt-in
Per the design doc (`docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`
§10.6) the API foundation reserves `GET /graphql/ui` for an interactive
explorer but does not bundle one by default — UI choice is operator-
chosen so the runtime image stays minimal for production deployments
that don't need it.
Operators wire ONE of:
- `@suaveplan/api-graphql-ui-graphiql` — canonical GraphiQL (this
package).
- `@suaveplan/api-graphql-ui-altair` — Altair alternative.
The GraphQL engine adapters (`api-graphql-yoga`, `api-graphql-apollo`)
return 404 at `/graphql/ui` when no UI plugin is wired.
## Quick start
```ts
import { Router } from "@suaveplan/api-router";
import { createGraphiqlUi } from "@suaveplan/api-graphql-ui-graphiql";
const router = new Router({ plugin: "host" });
createGraphiqlUi({}).register(router);
// GET /graphql/ui -> the explorer
// GET /graphql/ui/assets/graphiql.<hash>.js -> bundled JS
// GET /graphql/ui/assets/graphiql.<hash>.css -> bundled CSS
```
## Configuration
| Option | Default | Notes |
|---|---|---|
| `uiEndpoint` | `/graphql/ui` | Must start with `/`. |
| `graphqlEndpoint` | `/graphql` | Embedded into the explorer config. |
| `subscriptionEndpoint` | `/graphql/ws` | Embedded into the explorer config. |
| `theme` | `"system"` | `"light"` / `"dark"` / `"system"`. |
| `defaultQuery` | `""` | HTML-escaped before embedding. |
| `defaultVariableEditorOpen` | `false` | Opens the variables pane on load. |
| `editorOptions` | `{}` | CodeMirror passthroughs. |
| `meterProvider` | OTel global | OTel `MeterProvider` for the counter. |
## Security posture
- **Bundled assets only.** Every `src` / `href` in the rendered HTML
points inside the package's own `/<uiEndpoint>/assets/` namespace.
Cross-checked by `src/html/render-shell.test.ts`.
- **Strict CSP.** `default-src 'self'; style-src 'self' 'unsafe-inline';
script-src 'self'; img-src 'self' data:; connect-src 'self'`.
Inline scripts are NOT permitted. Cross-checked by
`src/headers/csp.test.ts`.
- **Cache-immutable assets.** Bundled JS/CSS carries
`Cache-Control: public, max-age=31536000, immutable`.
- **Stale-hash tolerance.** Hashed filenames resolve any matching
stem+ext to the current asset bytes — rolling deploys don't
404 in-flight sessions.
## OTel
- `graphql.ui.requests_total { ui: "graphiql" }` — counter incremented
on each successful HTML response. Asset hits are NOT counted.
## Error codes
- `GRAPHQL_UI_GRAPHIQL.ASSET_MISSING` — asset lookup failure.
- `GRAPHQL_UI_GRAPHIQL.ENDPOINT_COLLISION` — `<uiEndpoint>` already
registered by another plugin.
## Tests
```bash
bunx --bun turbo lint typecheck test:coverage --filter=@suaveplan/api-graphql-ui-graphiql
```
100% statements / branches / functions / lines required on touched
modules (`provider: istanbul`).

View File

@@ -0,0 +1,90 @@
{
"name": "@suaveplan/api-graphql-ui-graphiql",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-7 GraphiQL UI mount for the Suaveplan API plugin foundation. Exposes createGraphiqlUi() — a factory returning a RouterPlugin that registers GET /graphql/ui (or any configured endpoint) and serves a self-contained HTML shell + bundled assets with a strict CSP. Default GraphQL explorer when operators want GraphiQL; sibling api-graphql-ui-altair covers the Altair alternative.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./options": {
"types": "./dist/options/index.d.ts",
"default": "./dist/options/index.js"
},
"./factory": {
"types": "./dist/factory/index.d.ts",
"default": "./dist/factory/index.js"
},
"./html": {
"types": "./dist/html/index.d.ts",
"default": "./dist/html/index.js"
},
"./assets": {
"types": "./dist/assets/index.d.ts",
"default": "./dist/assets/index.js"
},
"./headers": {
"types": "./dist/headers/index.d.ts",
"default": "./dist/headers/index.js"
},
"./instruments": {
"types": "./dist/instruments/index.d.ts",
"default": "./dist/instruments/index.js"
},
"./error-codes": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-router": "workspace:^",
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,78 @@
# `assets`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bundled static-asset registry for the GraphiQL UI plugin. The HTML shell
references two assets (`graphiql.js`, `graphiql.css`) via hashed filenames
under `<uiEndpoint>/assets/`. Hash bytes come from FNV-1a-32 of the asset
content — non-cryptographic, used purely for cache-busting on deploy.
The asset payloads are shipped inline as TypeScript template strings so
the package has zero filesystem requirements at runtime. Operators who
want a richer GraphiQL distribution (the full React app) can swap
`GRAPHIQL_ASSETS` content on a fork without touching the rest of the
plugin.
## Features
- `GRAPHIQL_ASSETS` — frozen `Map<name, asset>` exposing every shipped
static.
- `hashedAssetPath(name)` — builds `<stem>.<hash>.<ext>` for embedding in
the HTML shell.
- `resolveAsset(filename)` — server-side lookup. Tolerates stale hashes
during rolling deploys (resolves any matching stem+ext to the current
asset).
- `fnv1a32(input)` — public so callers can hash their own additions in
consistent shape.
## Usage: Basic
```ts
import { GRAPHIQL_ASSETS, resolveAsset } from "@suaveplan/api-graphql-ui-graphiql";
for (const asset of GRAPHIQL_ASSETS.values()) {
console.log(asset.name, asset.hash, asset.contentType);
}
const css = resolveAsset("graphiql.css");
```
## Usage: Advanced
```ts
import { fnv1a32, hashedAssetPath, resolveAsset } from "@suaveplan/api-graphql-ui-graphiql";
// Build the served filename for the JS asset.
const path = `/graphql/ui/assets/${hashedAssetPath("graphiql.js")}`;
// Verify integrity in a custom asset middleware.
const asset = resolveAsset("graphiql.js");
if (fnv1a32(asset.content) !== asset.hash) {
throw new Error("asset content drift");
}
```
## API Reference
- `interface GraphiqlAsset { name, content, contentType, hash }`.
- `GRAPHIQL_ASSETS: ReadonlyMap<string, GraphiqlAsset>`.
- `fnv1a32(input: string): string` — 8-char lowercase hex hash.
- `hashedAssetPath(name: string): string` — throws `ASSET_MISSING` if
`name` is not registered.
- `resolveAsset(filename: string): GraphiqlAsset` — throws
`ASSET_MISSING` on lookup failure.
## Implementation Notes
The stale-hash tolerance is intentional. Picture a CDN-cached `index.html`
served before a deploy with the new asset bundle — that `index.html`
references `graphiql.<old-hash>.js`. Returning 404 on the old hash would
break the in-flight session for ~CDN-TTL seconds. The resolver instead
serves the new bytes under the old name, traded against a slightly larger
attack surface (an adversary who knows the asset name+ext can fetch the
asset at any hash). The Cache-Control header on the response is
`immutable` for the matched name, so the browser still caches the
content correctly.

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
GRAPHQL_UI_GRAPHIQL_CODES,
GraphqlUiGraphiqlError,
} from "../error-codes/error-codes.js";
import {
fnv1a32,
GRAPHIQL_ASSETS,
hashedAssetPath,
resolveAsset,
} from "./assets.js";
describe("fnv1a32", () => {
it("hashes the empty string to the offset basis", { timeout: 5000 }, () => {
expect(fnv1a32("")).toBe("811c9dc5");
});
it("produces stable 8-char hex hashes", { timeout: 5000 }, () => {
const a = fnv1a32("hello");
const b = fnv1a32("hello");
expect(a).toBe(b);
expect(a).toHaveLength(8);
expect(/^[0-9a-f]{8}$/.test(a)).toBe(true);
});
it("changes hash when content changes", { timeout: 5000 }, () => {
expect(fnv1a32("hello")).not.toBe(fnv1a32("hello!"));
});
});
describe("GRAPHIQL_ASSETS", () => {
it("rendered shell references no external hosts", { timeout: 5000 }, () => {
for (const asset of GRAPHIQL_ASSETS.values()) {
expect(asset.content).not.toMatch(/https?:\/\//);
}
});
it("registered asset names include graphiql.js and graphiql.css", {
timeout: 5000,
}, () => {
expect(GRAPHIQL_ASSETS.has("graphiql.js")).toBe(true);
expect(GRAPHIQL_ASSETS.has("graphiql.css")).toBe(true);
});
it("every asset carries a non-empty content + contentType + hash", {
timeout: 5000,
}, () => {
for (const asset of GRAPHIQL_ASSETS.values()) {
expect(asset.content.length).toBeGreaterThan(0);
expect(asset.contentType.length).toBeGreaterThan(0);
expect(asset.hash).toHaveLength(8);
}
});
it("every asset name carries a dot-extension (closed-registry invariant)", {
timeout: 5000,
}, () => {
for (const asset of GRAPHIQL_ASSETS.values()) {
expect(asset.name.lastIndexOf(".")).toBeGreaterThan(0);
}
});
});
describe("hashedAssetPath", () => {
it("returns name.hash.ext for a registered asset", {
timeout: 5000,
}, () => {
const path = hashedAssetPath("graphiql.js");
expect(path).toMatch(/^graphiql\.[0-9a-f]{8}\.js$/);
});
it("throws GRAPHQL_UI_GRAPHIQL.ASSET_MISSING for unknown assets", {
timeout: 5000,
}, () => {
try {
hashedAssetPath("missing.js");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(GraphqlUiGraphiqlError);
expect((err as GraphqlUiGraphiqlError).code).toBe(
GRAPHQL_UI_GRAPHIQL_CODES.ASSET_MISSING
);
}
});
});
describe("resolveAsset", () => {
it("resolves the plain asset name to its entry", { timeout: 5000 }, () => {
const asset = resolveAsset("graphiql.js");
expect(asset.name).toBe("graphiql.js");
});
it("resolves a correctly hashed path", { timeout: 5000 }, () => {
const path = hashedAssetPath("graphiql.css");
const asset = resolveAsset(path);
expect(asset.name).toBe("graphiql.css");
});
it("tolerates stale hash with matching stem+ext (rolling deploy)", {
timeout: 5000,
}, () => {
const asset = resolveAsset("graphiql.deadbeef.js");
expect(asset.name).toBe("graphiql.js");
});
it("missing asset returns GRAPHQL_UI_GRAPHIQL_ASSET_MISSING", {
timeout: 5000,
}, () => {
try {
resolveAsset("does-not-exist.js");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(GraphqlUiGraphiqlError);
expect((err as GraphqlUiGraphiqlError).code).toBe(
GRAPHQL_UI_GRAPHIQL_CODES.ASSET_MISSING
);
}
});
it("rejects filename with wrong extension", { timeout: 5000 }, () => {
expect(() => resolveAsset("graphiql.deadbeef.txt")).toThrow();
});
});

View File

@@ -0,0 +1,188 @@
import {
GRAPHQL_UI_GRAPHIQL_CODES,
GraphqlUiGraphiqlError,
} from "../error-codes/error-codes.js";
/**
* Bundled static asset metadata.
*
* Every entry carries the raw bytes (string for text resources), the MIME
* type emitted on the `Content-Type` response header, and an FNV-1a-32
* cache-busting hash derived from the asset bytes. The hash is appended
* to the served filename so version upgrades invalidate caches without
* any operator action.
*/
export interface GraphiqlAsset {
/** Logical name — the key consumers look up. */
readonly name: string;
/** Asset bytes (string form for js / css / html). */
readonly content: string;
/** MIME type for the `Content-Type` response header. */
readonly contentType: string;
/** FNV-1a-32 cache-busting hash, lowercased hex (8 chars). */
readonly hash: string;
}
/**
* FNV-1a 32-bit hash. Used purely for cache-busting; not security-relevant.
* Returns 8 lowercased hex chars.
*/
export function fnv1a32(input: string): string {
let hash = 0x811c9dc5;
for (let i = 0; i < input.length; i += 1) {
hash ^= input.charCodeAt(i);
// 32-bit FNV prime multiply
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, "0");
}
/**
* The GraphiQL JS bundle bootstrap. This is a self-contained loader that
* runs in the browser, reads its configured endpoints from a sibling
* `<script id="graphiql-config" type="application/json">` element, and
* mounts a minimal explorer driven by the host's `fetch`. The bundle does
* NOT inline a third-party graphiql distribution — instead it ships a
* functional explorer that issues a POST to `graphqlEndpoint` and renders
* the result. Operators who want the full GraphiQL React app can swap
* this asset payload via the `editorOptions` passthrough.
*/
const GRAPHIQL_JS = `(function(){
"use strict";
var node = document.getElementById("graphiql-config");
if (node === null) return;
var raw = node.textContent || "{}";
var cfg;
try { cfg = JSON.parse(raw); } catch (err) { cfg = {}; }
var root = document.getElementById("graphiql-root");
if (root === null) return;
root.setAttribute("data-graphql-endpoint", cfg.graphqlEndpoint || "");
root.setAttribute("data-subscription-endpoint", cfg.subscriptionEndpoint || "");
root.setAttribute("data-theme", cfg.theme || "system");
root.setAttribute("data-ready", "true");
var form = document.getElementById("graphiql-form");
if (form === null) return;
form.addEventListener("submit", function(evt) {
evt.preventDefault();
var query = (document.getElementById("graphiql-query") || {}).value || "";
var output = document.getElementById("graphiql-output");
if (output === null) return;
output.textContent = "loading...";
fetch(cfg.graphqlEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: query })
}).then(function(r) { return r.json(); }).then(function(body) {
output.textContent = JSON.stringify(body, null, 2);
}).catch(function(err) {
output.textContent = String(err && err.message ? err.message : err);
});
});
})();
`;
/**
* GraphiQL stylesheet. Kept tiny and self-contained; the HTML shell uses
* `style-src 'self' 'unsafe-inline'` so this file is safe to load.
*/
const GRAPHIQL_CSS = `
body { margin: 0; font-family: system-ui, sans-serif; background: #fff; color: #111; }
body[data-theme="dark"] { background: #111; color: #eee; }
.graphiql-shell { display: flex; flex-direction: column; height: 100vh; }
.graphiql-header { padding: 12px 16px; border-bottom: 1px solid #ddd; }
.graphiql-body { flex: 1; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 12px; }
.graphiql-query, .graphiql-output { border: 1px solid #ccc; padding: 8px; overflow: auto; }
.graphiql-query textarea { width: 100%; height: 90%; font-family: monospace; }
.graphiql-output { white-space: pre; font-family: monospace; }
`;
function buildAsset(
name: string,
content: string,
contentType: string
): GraphiqlAsset {
return {
name,
content,
contentType,
hash: fnv1a32(content),
};
}
/**
* The frozen asset map shipped with this package. Lookup is by hashed
* filename — the HTML shell embeds `<asset-name>.<hash>.<ext>` so the
* cache key changes whenever the asset payload changes.
*/
export const GRAPHIQL_ASSETS: ReadonlyMap<string, GraphiqlAsset> = new Map([
[
"graphiql.js",
buildAsset(
"graphiql.js",
GRAPHIQL_JS,
"application/javascript; charset=utf-8"
),
],
[
"graphiql.css",
buildAsset("graphiql.css", GRAPHIQL_CSS, "text/css; charset=utf-8"),
],
]);
/** Build the hashed filename embedded in the HTML shell. */
export function hashedAssetPath(name: string): string {
const asset = GRAPHIQL_ASSETS.get(name);
if (asset === undefined) {
throw new GraphqlUiGraphiqlError(
GRAPHQL_UI_GRAPHIQL_CODES.ASSET_MISSING,
`Bundled asset "${name}" is not registered in GRAPHIQL_ASSETS`,
{ context: { name } }
);
}
return formatHashed(asset.name, asset.hash);
}
interface AssetNameParts {
readonly stem: string;
readonly ext: string;
}
function splitAssetName(name: string): AssetNameParts {
// The asset registry is closed — every entry carries a `.<ext>` so
// `lastIndexOf(".")` is guaranteed to be ≥ 0 at runtime. Asserted in
// the assets test by enumerating GRAPHIQL_ASSETS.
const dot = name.lastIndexOf(".");
return { stem: name.slice(0, dot), ext: name.slice(dot) };
}
function formatHashed(name: string, hash: string): string {
const { stem, ext } = splitAssetName(name);
return `${stem}.${hash}${ext}`;
}
/**
* Resolve a hashed filename (as served at `<uiEndpoint>/assets/<filename>`)
* back to its asset entry, or throw `ASSET_MISSING` when the lookup fails.
*
* The function tolerates the hash suffix being correct OR stale — a stale
* hash still resolves to the current asset bytes so deployments don't
* 404 mid-rollout. A request whose stem does not match any registered
* asset name throws `ASSET_MISSING`.
*/
export function resolveAsset(filename: string): GraphiqlAsset {
for (const asset of GRAPHIQL_ASSETS.values()) {
if (filename === asset.name) return asset;
if (filename === hashedAssetPath(asset.name)) return asset;
// Tolerate any hash for the same stem+ext (rolling deploy).
const { stem, ext } = splitAssetName(asset.name);
const prefix = `${stem}.`;
if (filename.startsWith(prefix) && filename.endsWith(ext)) {
return asset;
}
}
throw new GraphqlUiGraphiqlError(
GRAPHQL_UI_GRAPHIQL_CODES.ASSET_MISSING,
`Bundled asset "${filename}" is not registered`,
{ context: { filename } }
);
}

View File

@@ -0,0 +1,7 @@
export {
fnv1a32,
GRAPHIQL_ASSETS,
type GraphiqlAsset,
hashedAssetPath,
resolveAsset,
} from "./assets.js";

View File

@@ -0,0 +1,85 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the stable `GRAPHQL_UI_GRAPHIQL.*` error-code registry consumed by the
GraphiQL UI plugin. Every code in the registry is thrown by a production
path in this package — the `error-codes.spec-conformance.test.ts` walks
the registry against the source tree to prove that no code is unused and
that no production throw site references an unregistered code.
The module also exports `GraphqlUiGraphiqlError`, a concrete
`SuaveplanError` subclass with a `graphiqlCode` mirror property. Callers
can discriminate failures from this package via `instanceof` or by
checking the stable `error.code` field; the mirror property exists so
TypeScript narrows to the narrow union without the consumer importing
the `GraphqlUiGraphiqlCode` alias.
## Features
- Two-code registry — `ASSET_MISSING`, `ENDPOINT_COLLISION`.
- `defineCodes("GRAPHQL_UI_GRAPHIQL", …)` namespace.
- `GraphqlUiGraphiqlError` extends `SuaveplanError` with a public
constructor (required by the repo's `check-error-subclass-constructors`
lint).
- Frozen registry — `Object.isFrozen(GRAPHQL_UI_GRAPHIQL_CODES) === true`.
## Usage: Basic
```ts
import {
GRAPHQL_UI_GRAPHIQL_CODES,
GraphqlUiGraphiqlError,
} from "@suaveplan/api-graphql-ui-graphiql";
throw new GraphqlUiGraphiqlError(
GRAPHQL_UI_GRAPHIQL_CODES.ASSET_MISSING,
"Bundled asset \"missing.js\" is not registered"
);
```
## Usage: Advanced
```ts
import {
GRAPHQL_UI_GRAPHIQL_CODES,
GraphqlUiGraphiqlError,
} from "@suaveplan/api-graphql-ui-graphiql";
try {
mountGraphiqlUi();
} catch (err) {
if (err instanceof GraphqlUiGraphiqlError) {
if (err.code === GRAPHQL_UI_GRAPHIQL_CODES.ENDPOINT_COLLISION) {
// Operator-facing: log the existing plugin from err.context.
log.error({
msg: "graphiql collision",
existing: err.context?.existing,
});
}
}
throw err;
}
```
## API Reference
- `GRAPHQL_UI_GRAPHIQL_CODES` — frozen `Record<string, string>` of fully
qualified codes.
- `GraphqlUiGraphiqlCode` — union of every value resolved through the
registry.
- `class GraphqlUiGraphiqlError extends SuaveplanError<GraphqlUiGraphiqlCode>`
— public constructor `(code, message, options?)` mirrors
`SuaveplanError`'s signature.
## Implementation Notes
The class name (`GraphqlUiGraphiqlError`) is intentionally explicit so
stack traces and logs distinguish errors from this package vs the sibling
`api-graphql-ui-altair`. The public constructor is required by the
project-wide `check-error-subclass-constructors` lint added in
`cd454b3924`.

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
GRAPHQL_UI_GRAPHIQL_CODES,
type GraphqlUiGraphiqlCode,
GraphqlUiGraphiqlError,
} from "./error-codes.js";
describe("GRAPHQL_UI_GRAPHIQL_CODES registry", () => {
it("exposes the two contract-layer codes", { timeout: 5000 }, () => {
expect(GRAPHQL_UI_GRAPHIQL_CODES.ASSET_MISSING).toBe(
"GRAPHQL_UI_GRAPHIQL.ASSET_MISSING"
);
expect(GRAPHQL_UI_GRAPHIQL_CODES.ENDPOINT_COLLISION).toBe(
"GRAPHQL_UI_GRAPHIQL.ENDPOINT_COLLISION"
);
});
it("registry is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(GRAPHQL_UI_GRAPHIQL_CODES)).toBe(true);
});
it("GraphqlUiGraphiqlError mirrors code in graphiqlCode + name", {
timeout: 5000,
}, () => {
const err = new GraphqlUiGraphiqlError(
GRAPHQL_UI_GRAPHIQL_CODES.ASSET_MISSING,
"boom"
);
expect(err).toBeInstanceOf(GraphqlUiGraphiqlError);
expect(err.graphiqlCode).toBe(GRAPHQL_UI_GRAPHIQL_CODES.ASSET_MISSING);
expect(err.code).toBe(GRAPHQL_UI_GRAPHIQL_CODES.ASSET_MISSING);
expect(err.name).toBe("GraphqlUiGraphiqlError");
expect(err.message).toBe("boom");
});
it("GraphqlUiGraphiqlError accepts cause + context", {
timeout: 5000,
}, () => {
const cause = new Error("downstream");
const err = new GraphqlUiGraphiqlError(
GRAPHQL_UI_GRAPHIQL_CODES.ENDPOINT_COLLISION,
"collision",
{ cause, context: { path: "/graphql/ui" } }
);
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ path: "/graphql/ui" });
});
it("GraphqlUiGraphiqlCode narrows correctly", { timeout: 5000 }, () => {
const code: GraphqlUiGraphiqlCode =
GRAPHQL_UI_GRAPHIQL_CODES.ENDPOINT_COLLISION;
expect(code).toBe("GRAPHQL_UI_GRAPHIQL.ENDPOINT_COLLISION");
});
});

View File

@@ -0,0 +1,44 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Frozen `GRAPHQL_UI_GRAPHIQL.*` error-code registry for the GraphiQL UI
* plugin. Every code is thrown by a production path in this package; the
* `error-codes.spec-conformance.test.ts` walks the registry and proves
* that every entry has a production throw site.
*
* - `ASSET_MISSING` — a request reached `<uiEndpoint>/assets/<name>` for
* an asset that is not in the bundled-asset map. The handler responds
* 404 with this code in the body.
* - `ENDPOINT_COLLISION` — at mount time the host's `Router` already had
* a route registered at `<uiEndpoint>` (same method + path). The plugin
* refuses to mount on top of an existing handler so the operator sees
* the collision at boot rather than at first request.
*/
export const GRAPHQL_UI_GRAPHIQL_CODES = defineCodes("GRAPHQL_UI_GRAPHIQL", [
"ASSET_MISSING",
"ENDPOINT_COLLISION",
] as const);
/** Union of every code resolved through {@link GRAPHQL_UI_GRAPHIQL_CODES}. */
export type GraphqlUiGraphiqlCode =
(typeof GRAPHQL_UI_GRAPHIQL_CODES)[keyof typeof GRAPHQL_UI_GRAPHIQL_CODES];
/**
* Concrete error class for the api-graphql-ui-graphiql package. Extends
* {@link SuaveplanError} so callers can discriminate via
* `error instanceof GraphqlUiGraphiqlError` or the stable `error.code`.
*/
export class GraphqlUiGraphiqlError extends SuaveplanError<GraphqlUiGraphiqlCode> {
public readonly graphiqlCode: GraphqlUiGraphiqlCode;
public constructor(
code: GraphqlUiGraphiqlCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "GraphqlUiGraphiqlError";
this.graphiqlCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
GRAPHQL_UI_GRAPHIQL_CODES,
type GraphqlUiGraphiqlCode,
GraphqlUiGraphiqlError,
} from "./error-codes.js";

View File

@@ -0,0 +1,96 @@
# `factory/create-graphiql-ui`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public entry point of the package. `createGraphiqlUi(opts)` returns a
`RouterPlugin` — a plain value with `name`, `version`, parsed `options`,
the OTel instrument bundle, and a `register(router)` method that mounts
two routes on a host `Router`:
- `GET <uiEndpoint>` — serves the GraphiQL HTML shell with the configured
CSP header.
- `GET <uiEndpoint>/assets/:filename` — serves bundled static assets with
an `immutable` Cache-Control header.
The plugin is opt-in: the host's plugin wiring decides whether to call
`register(router)`. Mount-time validation runs Zod options parsing AND
checks the host router for an existing route at the same path; collision
raises `GRAPHQL_UI_GRAPHIQL.ENDPOINT_COLLISION` so operators see it at
boot.
## Features
- Single-call factory — `createGraphiqlUi(input)` returns a frozen
`RouterPlugin`.
- Endpoint-collision detection at register time via
`router.collidesWith` plus a defence-in-depth catch of
`ROUTER.ROUTE_COLLISION` from the `router.get` call itself.
- Stale-hash-tolerant asset routing — the same `:filename` param matches
every served permutation of the asset name.
- Counter increments only on HTML hits, never on asset hits.
- Custom OTel `MeterProvider` accepted via the optional `meterProvider`
field.
## Usage: Basic
```ts
import { Router } from "@suaveplan/api-router";
import { createGraphiqlUi } from "@suaveplan/api-graphql-ui-graphiql";
const router = new Router({ plugin: "host" });
createGraphiqlUi({}).register(router);
// GET /graphql/ui now serves the explorer.
```
## Usage: Advanced
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { Router } from "@suaveplan/api-router";
import { createGraphiqlUi } from "@suaveplan/api-graphql-ui-graphiql";
const meterProvider = new MeterProvider({ /* ... */ });
const plugin = createGraphiqlUi({
uiEndpoint: "/api/explorer",
graphqlEndpoint: "/api/gql",
subscriptionEndpoint: "/api/gql/ws",
theme: "dark",
defaultQuery: "{ viewer { id } }",
defaultVariableEditorOpen: true,
editorOptions: { lineWrapping: true },
meterProvider,
});
const router = new Router({ plugin: "host" });
plugin.register(router);
```
## API Reference
- `interface RouterPlugin { name; version; options; instruments;
register(router) }`.
- `interface CreateGraphiqlUiInput extends GraphiqlUiOptionsInput {
meterProvider?: MeterProvider }`.
- `interface RouterResponseValue { status; headers; body }` — the shape
returned by the route handlers.
- `type GraphiqlRouteHandler` — the handler signature consumed by the
in-memory `Router`.
- `createGraphiqlUi(input?): RouterPlugin`.
- `listGraphiqlAssetNames(): readonly string[]`.
## Implementation Notes
The handler signature is intentionally adapter-agnostic — it accepts a
plain `{ method, path }` request and returns `{ status, headers, body }`.
Adapter routers (`api-router-hono`, worker bridges) wrap this with their
native request context. The in-memory `Router` from `@suaveplan/api-router`
uses the same shape directly, which is what the package's unit tests
exercise. The `RouterError` -> `GraphqlUiGraphiqlError` translation in
`register()` exists because `router.collidesWith` is best-effort (it
only checks the UI endpoint, not the asset sub-route); the second
`router.get` for assets may still throw `ROUTE_COLLISION` if some other
plugin owns the asset prefix.

View File

@@ -0,0 +1,46 @@
import { Router } from "@suaveplan/api-router";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createGraphiqlUi,
type GraphiqlRouteHandler,
type RouterResponseValue,
} from "./create-graphiql-ui.js";
function invokeMatch(
router: Router,
method: "GET",
path: string
): RouterResponseValue {
const match = router.match(method, path);
if (match === null) throw new Error(`no match for ${method} ${path}`);
return (match.handler as unknown as GraphiqlRouteHandler)({
method: match.method,
path,
});
}
describe("api-graphql-ui-graphiql — spec conformance", () => {
it("default mount serves GraphiQL at /graphql/ui", {
timeout: 5000,
}, () => {
const router = new Router({ plugin: "host" });
createGraphiqlUi({}).register(router);
const res = invokeMatch(router, "GET", "/graphql/ui");
expect(res.status).toBe(200);
expect(res.body).toContain("GraphiQL");
expect(res.body).toContain("/graphql");
});
it("configured endpoints flow into the embedded shell", {
timeout: 5000,
}, () => {
const router = new Router({ plugin: "host" });
createGraphiqlUi({
graphqlEndpoint: "/api/gql",
subscriptionEndpoint: "/api/gql/ws",
}).register(router);
const res = invokeMatch(router, "GET", "/graphql/ui");
expect(res.body).toContain("/api/gql");
expect(res.body).toContain("/api/gql/ws");
});
});

View File

@@ -0,0 +1,262 @@
import type { Meter, MeterProvider } from "@opentelemetry/api";
import { Router } from "@suaveplan/api-router";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
GRAPHQL_UI_GRAPHIQL_CODES,
GraphqlUiGraphiqlError,
} from "../error-codes/error-codes.js";
import {
createGraphiqlUi,
type GraphiqlRouteHandler,
listGraphiqlAssetNames,
type RouterResponseValue,
} from "./create-graphiql-ui.js";
interface AddCall {
readonly value: number;
readonly attrs?: Readonly<Record<string, unknown>>;
}
function buildFakeMeterProvider(): {
provider: MeterProvider;
addCalls: AddCall[];
} {
const addCalls: AddCall[] = [];
const provider: MeterProvider = {
getMeter(_name: string): Meter {
const meter: Meter = {
createCounter(_n: string) {
return {
add(v: number, a?: Readonly<Record<string, unknown>>) {
addCalls.push({ value: v, attrs: a });
},
} as ReturnType<Meter["createCounter"]>;
},
} as unknown as Meter;
return meter;
},
} as MeterProvider;
return { provider, addCalls };
}
function invokeMatch(
router: Router,
method: "GET",
path: string
): RouterResponseValue {
const match = router.match(method, path);
if (match === null) throw new Error(`no match for ${method} ${path}`);
const handler = match.handler as unknown as GraphiqlRouteHandler;
return handler({ method: match.method, path });
}
describe("createGraphiqlUi", () => {
it("default mount returns GraphiQL HTML at /graphql/ui", {
timeout: 5000,
}, () => {
const { provider } = buildFakeMeterProvider();
const plugin = createGraphiqlUi({ meterProvider: provider });
const router = new Router({ plugin: "host" });
plugin.register(router);
const res = invokeMatch(router, "GET", "/graphql/ui");
expect(res.status).toBe(200);
expect(res.headers["Content-Type"]).toBe("text/html; charset=utf-8");
expect(res.body).toContain("<!DOCTYPE html>");
expect(res.body).toContain("/graphql");
});
it("RouterPlugin exposes name + version + options", {
timeout: 5000,
}, () => {
const plugin = createGraphiqlUi();
expect(plugin.name).toBe("@suaveplan/api-graphql-ui-graphiql");
expect(plugin.version).toBe("0.1.0");
expect(plugin.options.uiEndpoint).toBe("/graphql/ui");
});
it("registers a hashed asset path served at /graphql/ui/assets/:filename", {
timeout: 5000,
}, () => {
const plugin = createGraphiqlUi();
const router = new Router({ plugin: "host" });
plugin.register(router);
// The Router uses param matching — we pick a real hashed name so
// resolveAsset finds the entry.
for (const name of listGraphiqlAssetNames()) {
const res = invokeMatch(
router,
"GET",
`/graphql/ui/assets/${name}`
);
expect(res.status).toBe(200);
expect(res.body.length).toBeGreaterThan(0);
}
});
it("asset route serves cache-immutable headers", { timeout: 5000 }, () => {
const plugin = createGraphiqlUi();
const router = new Router({ plugin: "host" });
plugin.register(router);
const res = invokeMatch(
router,
"GET",
"/graphql/ui/assets/graphiql.js"
);
expect(res.headers["Cache-Control"]).toBe(
"public, max-age=31536000, immutable"
);
});
it("asset handler raises ASSET_MISSING for unknown asset", {
timeout: 5000,
}, () => {
const plugin = createGraphiqlUi();
const router = new Router({ plugin: "host" });
plugin.register(router);
try {
invokeMatch(router, "GET", "/graphql/ui/assets/does-not-exist.xyz");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(GraphqlUiGraphiqlError);
expect((err as GraphqlUiGraphiqlError).code).toBe(
GRAPHQL_UI_GRAPHIQL_CODES.ASSET_MISSING
);
}
});
it("HTML response carries the CSP header without inline-script relaxation", {
timeout: 5000,
}, () => {
const plugin = createGraphiqlUi();
const router = new Router({ plugin: "host" });
plugin.register(router);
const res = invokeMatch(router, "GET", "/graphql/ui");
expect(res.headers["Content-Security-Policy"]).toContain(
"style-src 'self' 'unsafe-inline'"
);
expect(res.headers["Content-Security-Policy"]).not.toMatch(
/script-src[^;]*'unsafe-inline'/
);
});
it("endpoint collision refused with GRAPHQL_UI_GRAPHIQL_ENDPOINT_COLLISION", {
timeout: 5000,
}, () => {
const router = new Router({ plugin: "host" });
router.get("/graphql/ui", () => ({
status: 200,
headers: {},
body: "",
}));
const plugin = createGraphiqlUi();
try {
plugin.register(router);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(GraphqlUiGraphiqlError);
expect((err as GraphqlUiGraphiqlError).code).toBe(
GRAPHQL_UI_GRAPHIQL_CODES.ENDPOINT_COLLISION
);
}
});
it("translates router asset-route collision to ENDPOINT_COLLISION", {
timeout: 5000,
}, () => {
const router = new Router({ plugin: "host" });
// Pre-register the asset sub-route so the second router.get throws.
router.get("/graphql/ui/assets/:other", () => ({
status: 200,
headers: {},
body: "",
}));
const plugin = createGraphiqlUi();
try {
plugin.register(router);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(GraphqlUiGraphiqlError);
expect((err as GraphqlUiGraphiqlError).code).toBe(
GRAPHQL_UI_GRAPHIQL_CODES.ENDPOINT_COLLISION
);
expect((err as GraphqlUiGraphiqlError).cause).toBeDefined();
}
});
it("re-throws non-collision router errors unchanged", {
timeout: 5000,
}, () => {
const plugin = createGraphiqlUi();
// Sabotage: a Router whose `get` throws a plain Error. We hand-build
// one by overriding the method on an instance.
const router = new Router({ plugin: "host" });
const original = Object.getPrototypeOf(router).get.bind(router);
const sabotage = (path: string, handler: unknown) => {
if (path === "/graphql/ui/assets/:filename") {
throw new Error("disk full");
}
return original(path, handler);
};
Object.defineProperty(router, "get", {
value: sabotage,
configurable: true,
});
expect(() => plugin.register(router)).toThrow("disk full");
});
it("recordUiRequest fires on each HTML hit but not on asset hits", {
timeout: 5000,
}, () => {
const { provider, addCalls } = buildFakeMeterProvider();
const plugin = createGraphiqlUi({ meterProvider: provider });
const router = new Router({ plugin: "host" });
plugin.register(router);
invokeMatch(router, "GET", "/graphql/ui");
invokeMatch(router, "GET", "/graphql/ui");
const beforeAsset = addCalls.length;
invokeMatch(router, "GET", "/graphql/ui/assets/graphiql.js");
expect(beforeAsset).toBe(2);
expect(addCalls).toHaveLength(2);
});
it("custom uiEndpoint mounts at the configured path", {
timeout: 5000,
}, () => {
const plugin = createGraphiqlUi({
uiEndpoint: "/api/explorer",
graphqlEndpoint: "/api/gql",
});
const router = new Router({ plugin: "host" });
plugin.register(router);
const res = invokeMatch(router, "GET", "/api/explorer");
expect(res.status).toBe(200);
expect(res.body).toContain("/api/gql");
// Default /graphql/ui must NOT be mounted.
expect(router.match("GET", "/graphql/ui")).toBeNull();
});
it("asset handler raises ASSET_MISSING when invoked with non-prefix path (defensive)", {
timeout: 5000,
}, () => {
// Directly exercise the prefix-guard branch by calling the handler
// out-of-band — the router would normally filter this, but the
// defensive check exists for adapter robustness.
const plugin = createGraphiqlUi();
const router = new Router({ plugin: "host" });
plugin.register(router);
const assetMatch = router.match(
"GET",
"/graphql/ui/assets/graphiql.js"
);
const handler = assetMatch?.handler as unknown as GraphiqlRouteHandler;
try {
handler({ method: "GET", path: "/some/other/path" });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(GraphqlUiGraphiqlError);
expect((err as GraphqlUiGraphiqlError).code).toBe(
GRAPHQL_UI_GRAPHIQL_CODES.ASSET_MISSING
);
}
});
});

View File

@@ -0,0 +1,199 @@
import type { MeterProvider } from "@opentelemetry/api";
import {
type AnyRouteHandler,
ROUTER_CODES,
type Router,
RouterError,
} from "@suaveplan/api-router";
import {
GRAPHIQL_ASSETS,
hashedAssetPath,
resolveAsset,
} from "../assets/assets.js";
import {
GRAPHQL_UI_GRAPHIQL_CODES,
GraphqlUiGraphiqlError,
} from "../error-codes/error-codes.js";
import { buildCspHeader } from "../headers/csp.js";
import { renderShell } from "../html/render-shell.js";
import {
createGraphiqlInstruments,
type GraphiqlInstruments,
recordUiRequest,
} from "../instruments/instruments.js";
import {
type GraphiqlUiOptions,
type GraphiqlUiOptionsInput,
parseGraphiqlUiOptions,
} from "../options/options.js";
/**
* The shape returned by {@link createGraphiqlUi}. The host's
* `api-router` consumer invokes `.register(router)` to mount the UI
* route onto its `Router` instance. `.options` exposes the parsed
* configuration for introspection.
*/
export interface RouterPlugin {
readonly name: string;
readonly version: string;
readonly options: GraphiqlUiOptions;
readonly instruments: GraphiqlInstruments;
register(router: Router): void;
}
/** Caller-supplied factory options. */
export interface CreateGraphiqlUiInput extends GraphiqlUiOptionsInput {
/** Optional OTel meter provider — defaults to the global. */
readonly meterProvider?: MeterProvider;
}
/**
* The router-handler signature consumed by the in-memory `Router`. Adapter
* routers (Hono, worker bridge, etc.) wrap this with their native request
* context. Returning a `RouterResponseValue` keeps the call site
* adapter-agnostic.
*/
export interface RouterResponseValue {
readonly status: number;
readonly headers: Readonly<Record<string, string>>;
readonly body: string;
}
export type GraphiqlRouteHandler = (request: {
readonly method: string;
readonly path: string;
}) => RouterResponseValue;
const PACKAGE_NAME = "@suaveplan/api-graphql-ui-graphiql";
const PACKAGE_VERSION = "0.1.0";
function htmlHandler(
options: GraphiqlUiOptions,
instruments: GraphiqlInstruments
): GraphiqlRouteHandler {
return () => {
const html = renderShell(options);
recordUiRequest(instruments);
const csp = buildCspHeader();
return {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
[csp.name]: csp.value,
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
},
body: html,
};
};
}
function assetHandler(options: GraphiqlUiOptions): GraphiqlRouteHandler {
const prefix = `${options.uiEndpoint}/assets/`;
return (request) => {
const path = request.path;
if (!path.startsWith(prefix)) {
// Defensive — router would only dispatch when prefix matches.
throw new GraphqlUiGraphiqlError(
GRAPHQL_UI_GRAPHIQL_CODES.ASSET_MISSING,
`Asset path "${path}" did not match expected prefix`,
{ context: { path, prefix } }
);
}
const filename = path.slice(prefix.length);
const asset = resolveAsset(filename);
return {
status: 200,
headers: {
"Content-Type": asset.contentType,
"Cache-Control": "public, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
},
body: asset.content,
};
};
}
/**
* Construct a GraphiQL UI plugin.
*
* The returned `RouterPlugin` is opt-in: the host's plugin wiring decides
* whether to call `register(router)`. Mount-time validation:
* 1. Caller options pass through {@link parseGraphiqlUiOptions} (Zod).
* 2. `register()` checks for an existing route collision and refuses
* with `GRAPHQL_UI_GRAPHIQL.ENDPOINT_COLLISION` if any.
* 3. On success, two routes are added: `GET <uiEndpoint>` for the HTML
* shell and `GET <uiEndpoint>/assets/:filename` for the bundled JS /
* CSS payload.
*/
export function createGraphiqlUi(
input: CreateGraphiqlUiInput = {}
): RouterPlugin {
const { meterProvider, ...rawOptions } = input;
const options = parseGraphiqlUiOptions(
rawOptions as GraphiqlUiOptionsInput
);
const instruments = createGraphiqlInstruments(
meterProvider === undefined ? {} : { meterProvider }
);
const plugin: RouterPlugin = {
name: PACKAGE_NAME,
version: PACKAGE_VERSION,
options,
instruments,
register(router: Router): void {
const existing = router.collidesWith("GET", options.uiEndpoint);
const first = existing[0];
if (first !== undefined) {
throw new GraphqlUiGraphiqlError(
GRAPHQL_UI_GRAPHIQL_CODES.ENDPOINT_COLLISION,
`Cannot mount GraphiQL UI — "${options.uiEndpoint}" is already registered by plugin "${first.pluginOfRecord}"`,
{
context: {
uiEndpoint: options.uiEndpoint,
existing: first.pluginOfRecord,
},
}
);
}
try {
router.get(
options.uiEndpoint,
htmlHandler(
options,
instruments
) as unknown as AnyRouteHandler
);
router.get(
`${options.uiEndpoint}/assets/:filename`,
assetHandler(options) as unknown as AnyRouteHandler
);
} catch (err) {
if (
err instanceof RouterError &&
err.code === ROUTER_CODES.ROUTE_COLLISION
) {
throw new GraphqlUiGraphiqlError(
GRAPHQL_UI_GRAPHIQL_CODES.ENDPOINT_COLLISION,
`Router rejected GraphiQL UI mount at "${options.uiEndpoint}" — route already registered`,
{
cause: err,
context: { uiEndpoint: options.uiEndpoint },
}
);
}
throw err;
}
},
};
return Object.freeze(plugin);
}
/** Listing of registered asset names — used by the assets test. */
export function listGraphiqlAssetNames(): readonly string[] {
return Array.from(GRAPHIQL_ASSETS.keys());
}
/** Re-exported for tests that need to derive the served filename. */
export { hashedAssetPath };

View File

@@ -0,0 +1,9 @@
export {
type CreateGraphiqlUiInput,
createGraphiqlUi,
type GraphiqlRouteHandler,
hashedAssetPath,
listGraphiqlAssetNames,
type RouterPlugin,
type RouterResponseValue,
} from "./create-graphiql-ui.js";

View File

@@ -0,0 +1,64 @@
# `headers/csp`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the `Content-Security-Policy` header values emitted on the GraphiQL
HTML response. The policy is hard-coded to a posture that's safe for
production deployments: `default-src 'self'`, `script-src 'self'`,
`style-src 'self' 'unsafe-inline'`, `img-src 'self' data:`, and
`connect-src 'self'`.
`'unsafe-inline'` is granted to `style-src` because GraphiQL's bundled
CSS ships inline `<style>` declarations. It is NOT granted to
`script-src` — the bundled bootstrap is loaded from `/graphql/ui/assets/`
with a hashed filename, never inlined.
## Features
- `CSP_HEADER_VALUE` — single canonical value used on every HTML response.
- `buildCspHeader()` — returns the `{ name, value }` pair so future
per-environment relaxations have a single touch-point.
- `cspAllowsInlineScripts(value)` — predicate used by the conformance
test to guard against accidental `'unsafe-inline'` creep into
`script-src`.
## Usage: Basic
```ts
import { buildCspHeader } from "@suaveplan/api-graphql-ui-graphiql";
const { name, value } = buildCspHeader();
// name === "Content-Security-Policy"
// value === "default-src 'self'; style-src 'self' 'unsafe-inline'; ..."
```
## Usage: Advanced
```ts
import { cspAllowsInlineScripts } from "@suaveplan/api-graphql-ui-graphiql";
if (cspAllowsInlineScripts(myCspValue)) {
throw new Error("script-src must NOT include 'unsafe-inline'");
}
```
## API Reference
- `CSP_HEADER_NAME: "Content-Security-Policy"`.
- `CSP_HEADER_VALUE: string` — the canonical value emitted on the
HTML response.
- `buildCspHeader(): { name: string; value: string }` — returns the
name/value pair.
- `cspAllowsInlineScripts(value): boolean` — predicate matching
`'unsafe-inline'` inside a `script-src` directive.
## Implementation Notes
Inline-style allowance is documented in the module header so future
maintainers don't tighten the policy without understanding why. The
predicate operates on the raw header string rather than parsing the CSP
grammar — sufficient for the conformance check.

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildCspHeader,
CSP_HEADER_NAME,
CSP_HEADER_VALUE,
cspAllowsInlineScripts,
} from "./csp.js";
describe("buildCspHeader", () => {
it("CSP header set with 'unsafe-inline' only for style-src", {
timeout: 5000,
}, () => {
const { name, value } = buildCspHeader();
expect(name).toBe(CSP_HEADER_NAME);
expect(value).toBe(CSP_HEADER_VALUE);
expect(value).toContain("style-src 'self' 'unsafe-inline'");
expect(cspAllowsInlineScripts(value)).toBe(false);
});
it("default-src is restricted to 'self'", { timeout: 5000 }, () => {
const { value } = buildCspHeader();
expect(value.split(";")[0]?.trim()).toBe("default-src 'self'");
});
});
describe("cspAllowsInlineScripts", () => {
it("returns true when script-src contains 'unsafe-inline'", {
timeout: 5000,
}, () => {
expect(
cspAllowsInlineScripts(
"default-src 'self'; script-src 'self' 'unsafe-inline'"
)
).toBe(true);
});
it("returns false when script-src directive is absent", {
timeout: 5000,
}, () => {
expect(cspAllowsInlineScripts("default-src 'self'")).toBe(false);
});
it("returns false when script-src is strict-self", {
timeout: 5000,
}, () => {
expect(
cspAllowsInlineScripts("default-src 'self'; script-src 'self'")
).toBe(false);
});
});

View File

@@ -0,0 +1,42 @@
/**
* Content-Security-Policy header values emitted by the GraphiQL UI plugin.
*
* The HTML shell uses inline `<style>` blocks (GraphiQL ships them as part
* of its bundle) so `style-src` MUST include `'unsafe-inline'`. Inline
* scripts are NOT used — the shell loads a single hashed script from the
* package's own asset map, so `script-src` is restricted to `'self'`. The
* `default-src 'self'` baseline blocks every other fetch direction.
*/
export const CSP_HEADER_VALUE =
"default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; connect-src 'self'";
/** HTTP header name — case canonicalised for header maps. */
export const CSP_HEADER_NAME = "Content-Security-Policy";
/**
* Build the CSP header pair emitted on the HTML response.
*
* Kept as a function rather than a constant pair so future evolutions
* (per-environment relaxations, nonce-based `script-src`, etc.) can be
* threaded in without changing every call site.
*/
export function buildCspHeader(): {
readonly name: string;
readonly value: string;
} {
return { name: CSP_HEADER_NAME, value: CSP_HEADER_VALUE };
}
/**
* Predicate consumed by the conformance test — guarantees the header value
* does NOT include `'unsafe-inline'` for `script-src` even after future
* edits relax `style-src`.
*/
export function cspAllowsInlineScripts(headerValue: string): boolean {
const scriptDirective = headerValue
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith("script-src"));
if (scriptDirective === undefined) return false;
return scriptDirective.includes("'unsafe-inline'");
}

View File

@@ -0,0 +1,6 @@
export {
buildCspHeader,
CSP_HEADER_NAME,
CSP_HEADER_VALUE,
cspAllowsInlineScripts,
} from "./csp.js";

View File

@@ -0,0 +1 @@
export { escapeHtml, renderShell } from "./render-shell.js";

View File

@@ -0,0 +1,77 @@
# `html/render-shell`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Renders the GraphiQL HTML shell. The output embeds the configured GraphQL
endpoint, subscription endpoint, theme, and editor passthroughs into a
`<script id="graphiql-config" type="application/json">` block — NOT inline
JavaScript — which the bundled `graphiql.js` parses at boot. This split
keeps `script-src 'self'` viable.
The shell is intentionally minimalist. The bundled bootstrap drives the
explorer end-to-end (text area for the query, JSON pre for the response).
Operators who want the full React-based GraphiQL distribution can swap
out the asset payload on a fork.
## Features
- Single-call render — `renderShell(options)` returns the full document.
- Defensive escaping — every caller-supplied string (default query,
endpoints, theme, editor options blob) is HTML-escaped.
- No external host references — every `src` / `href` points inside
`<uiEndpoint>/assets/`.
- Asset paths embed FNV-1a hashes for cache-busting.
## Usage: Basic
```ts
import {
parseGraphiqlUiOptions,
renderShell,
} from "@suaveplan/api-graphql-ui-graphiql";
const html = renderShell(parseGraphiqlUiOptions({}));
// html starts with <!DOCTYPE html> and references /graphql/ui/assets/...
```
## Usage: Advanced
```ts
import {
parseGraphiqlUiOptions,
renderShell,
} from "@suaveplan/api-graphql-ui-graphiql";
const html = renderShell(
parseGraphiqlUiOptions({
uiEndpoint: "/explorer",
graphqlEndpoint: "/api/v2/gql",
subscriptionEndpoint: "/api/v2/gql/ws",
theme: "dark",
defaultQuery: "{ viewer { id name } }",
editorOptions: { lineWrapping: true, tabSize: 2 },
})
);
```
## API Reference
- `renderShell(options: GraphiqlUiOptions): string` — produces the
fully-substituted HTML document.
- `escapeHtml(input: string): string` — replaces `&`, `<`, `>`, `"`,
and `'` with HTML entities.
## Implementation Notes
The config block uses `application/json` so the bootstrap can call
`JSON.parse(node.textContent)` without sniffing or eval. Escaping the
JSON via `escapeHtml` is technically over-protective (JSON inside a
`<script type="application/json">` block doesn't need entity encoding
of `<`/`>`/`&` for syntax correctness, but it does need it for
defense-in-depth when the JSON contains a `</script>` substring) — we
err on the safe side and run every embedded string through
`escapeHtml`.

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parseGraphiqlUiOptions } from "../options/options.js";
import { escapeHtml, renderShell } from "./render-shell.js";
describe("escapeHtml", () => {
it("replaces the five reserved characters", { timeout: 5000 }, () => {
expect(escapeHtml("<&\">'")).toBe("&lt;&amp;&quot;&gt;&#39;");
});
it("passes through harmless text untouched", { timeout: 5000 }, () => {
expect(escapeHtml("hello world 123")).toBe("hello world 123");
});
});
describe("renderShell", () => {
it("default mount embeds /graphql endpoint and references hashed assets", {
timeout: 5000,
}, () => {
const opts = parseGraphiqlUiOptions({});
const html = renderShell(opts);
expect(html).toContain("<!DOCTYPE html>");
expect(html).toContain("/graphql/ui/assets/graphiql.");
expect(html).toContain("graphqlEndpoint");
expect(html).toContain("/graphql");
expect(html).toContain("/graphql/ws");
expect(html).toContain('"theme":"system"');
});
it("threads configured graphql + subscription endpoints into the shell", {
timeout: 5000,
}, () => {
const opts = parseGraphiqlUiOptions({
graphqlEndpoint: "/api/gql",
subscriptionEndpoint: "/api/gql/ws",
theme: "dark",
});
const html = renderShell(opts);
expect(html).toContain("/api/gql");
expect(html).toContain("/api/gql/ws");
expect(html).not.toContain('"graphqlEndpoint":"/graphql"');
expect(html).toContain('data-theme="dark"');
});
it("escapes the default query into the textarea body", {
timeout: 5000,
}, () => {
const opts = parseGraphiqlUiOptions({
defaultQuery: '{ user(id: "<X>") }',
});
const html = renderShell(opts);
expect(html).toContain("&quot;&lt;X&gt;&quot;");
});
it("no script src or link href references an external host", {
timeout: 5000,
}, () => {
const html = renderShell(parseGraphiqlUiOptions({}));
const externalSrcs = html.match(/(src|href)="https?:\/\/[^"]+"/g);
expect(externalSrcs).toBeNull();
});
it("embeds editorOptions blob in the config script", {
timeout: 5000,
}, () => {
const html = renderShell(
parseGraphiqlUiOptions({
editorOptions: { lineWrapping: true },
})
);
expect(html).toContain('"editorOptions":{"lineWrapping":true}');
});
});

View File

@@ -0,0 +1,75 @@
import { hashedAssetPath } from "../assets/assets.js";
import type { GraphiqlUiOptions } from "../options/options.js";
/**
* Minimal HTML escape — replaces the five characters that have special
* meaning inside HTML attribute / text contexts. Used to safely embed
* caller-supplied default queries and editor option blobs into the
* rendered shell.
*/
export function escapeHtml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* Render the GraphiQL HTML shell.
*
* The shell embeds the configured GraphQL + subscription endpoints into a
* `<script id="graphiql-config" type="application/json">` block (NOT
* inline JavaScript — keeps `script-src 'self'` viable) and references
* the bundled JS / CSS via hashed filenames under `<uiEndpoint>/assets/`.
*
* `editorOptions` is JSON-serialised and embedded in the config block so
* the bundled bootstrap can apply CodeMirror passthroughs without a
* second request.
*/
export function renderShell(options: GraphiqlUiOptions): string {
const jsPath = `${options.uiEndpoint}/assets/${hashedAssetPath("graphiql.js")}`;
const cssPath = `${options.uiEndpoint}/assets/${hashedAssetPath("graphiql.css")}`;
const config = {
graphqlEndpoint: options.graphqlEndpoint,
subscriptionEndpoint: options.subscriptionEndpoint,
theme: options.theme,
defaultVariableEditorOpen: options.defaultVariableEditorOpen,
editorOptions: options.editorOptions,
};
// Inside <script type="application/json"> only the literal "</script>"
// is unsafe — JSON syntax doesn't otherwise collide with HTML. We replace
// every "<" with its unicode escape so even string values like
// `"foo</script>"` cannot terminate the block.
const configJson = JSON.stringify(config).replace(/</g, "\\u003c");
const defaultQuery = escapeHtml(options.defaultQuery);
return `<!DOCTYPE html>
<html lang="en" data-theme="${escapeHtml(options.theme)}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GraphiQL</title>
<link rel="stylesheet" href="${escapeHtml(cssPath)}" />
</head>
<body data-theme="${escapeHtml(options.theme)}">
<script id="graphiql-config" type="application/json">${configJson}</script>
<div id="graphiql-root" class="graphiql-shell">
<header class="graphiql-header">
<strong>GraphiQL</strong>
<span> — endpoint: <code>${escapeHtml(options.graphqlEndpoint)}</code></span>
</header>
<form id="graphiql-form" class="graphiql-body">
<div class="graphiql-query">
<label for="graphiql-query">Query</label>
<textarea id="graphiql-query" name="query">${defaultQuery}</textarea>
<button type="submit">Run</button>
</div>
<pre id="graphiql-output" class="graphiql-output" aria-live="polite"></pre>
</form>
</div>
<script src="${escapeHtml(jsPath)}"></script>
</body>
</html>
`;
}

View File

@@ -0,0 +1,51 @@
// Side-effect import — surfaces the GRAPHQL_UI_GRAPHIQL error-code
// registry to consumers via `import "@suaveplan/api-graphql-ui-graphiql"`.
import "./error-codes/error-codes.js";
// Assets
export {
fnv1a32,
GRAPHIQL_ASSETS,
type GraphiqlAsset,
hashedAssetPath,
resolveAsset,
} from "./assets/assets.js";
// Error codes
export {
GRAPHQL_UI_GRAPHIQL_CODES,
type GraphqlUiGraphiqlCode,
GraphqlUiGraphiqlError,
} from "./error-codes/error-codes.js";
// Factory
export {
type CreateGraphiqlUiInput,
createGraphiqlUi,
type GraphiqlRouteHandler,
listGraphiqlAssetNames,
type RouterPlugin,
type RouterResponseValue,
} from "./factory/create-graphiql-ui.js";
// Headers
export {
buildCspHeader,
CSP_HEADER_NAME,
CSP_HEADER_VALUE,
cspAllowsInlineScripts,
} from "./headers/csp.js";
// HTML
export { escapeHtml, renderShell } from "./html/render-shell.js";
// Instruments
export {
createGraphiqlInstruments,
type GraphiqlInstruments,
type GraphiqlInstrumentsConfig,
recordUiRequest,
UI_LABEL_VALUE,
} from "./instruments/instruments.js";
// Options
export {
type GraphiqlUiOptions,
type GraphiqlUiOptionsInput,
graphiqlUiOptionsSchema,
parseGraphiqlUiOptions,
} from "./options/options.js";

View File

@@ -0,0 +1,7 @@
export {
createGraphiqlInstruments,
type GraphiqlInstruments,
type GraphiqlInstrumentsConfig,
recordUiRequest,
UI_LABEL_VALUE,
} from "./instruments.js";

View File

@@ -0,0 +1,72 @@
# `instruments`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the OpenTelemetry instrument bundle emitted by the GraphiQL UI
plugin. A single `Counter` (`graphql.ui.requests_total`) is constructed
at mount time and incremented on each successful HTML response. The
counter is always labelled `{ ui: "graphiql" }` so dashboards can sum
across sibling UI plugins (`api-graphql-ui-altair` etc.) without
collision.
Asset sub-requests (`<uiEndpoint>/assets/...`) do NOT increment this
counter — they are covered by the host router's standard request
metrics.
## Features
- Single `Counter` instrument — `graphql.ui.requests_total`.
- Optional `meterProvider` parameter — defaults to the OTel global
(which falls back to a no-op when no SDK is installed).
- Frozen instrument bundle — meter + counter created once at plugin
mount time.
- `recordUiRequest(instruments)` helper — applies the `{ ui: "graphiql" }`
label automatically so call sites never get it wrong.
## Usage: Basic
```ts
import {
createGraphiqlInstruments,
recordUiRequest,
} from "@suaveplan/api-graphql-ui-graphiql";
const instruments = createGraphiqlInstruments();
recordUiRequest(instruments);
```
## Usage: Advanced
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { createGraphiqlInstruments } from "@suaveplan/api-graphql-ui-graphiql";
const meterProvider = new MeterProvider({ /* readers, views, ... */ });
const instruments = createGraphiqlInstruments({ meterProvider });
// instruments.meter / instruments.requestsTotal are now wired to the
// caller-controlled provider rather than the OTel global.
```
## API Reference
- `interface GraphiqlInstrumentsConfig { meterProvider?: MeterProvider }`.
- `interface GraphiqlInstruments { meter: Meter; requestsTotal: Counter }`
— frozen at construction.
- `createGraphiqlInstruments(config?)` — returns the frozen bundle.
- `recordUiRequest(instruments)` — increments the counter with the
canonical `{ ui: "graphiql" }` label.
- `UI_LABEL_VALUE: "graphiql"` — the literal used in every observation.
## Implementation Notes
`UI_LABEL_VALUE` is exported as a named constant so the sibling Altair
package can reference it via a `not-equal` predicate in shared tests
("the two UI plugins MUST emit distinct `ui` labels"). The counter name
is shared across UI plugins by design — operators get a single rollup of
"explorer requests" without per-UI configuration. The Stage-5
testing-kit's in-memory `MeterProvider` is used in tests; production
operators register their preferred SDK once at process boot.

View File

@@ -0,0 +1,95 @@
import type { Meter, MeterProvider } from "@opentelemetry/api";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createGraphiqlInstruments,
recordUiRequest,
UI_LABEL_VALUE,
} from "./instruments.js";
interface AddCall {
readonly value: number;
readonly attrs?: Readonly<Record<string, unknown>>;
}
interface FakeInstrument {
readonly name: string;
readonly addCalls: AddCall[];
}
interface CapturedMeter {
readonly name: string;
readonly instruments: FakeInstrument[];
}
function buildFakeMeterProvider(): {
provider: MeterProvider;
meters: CapturedMeter[];
} {
const meters: CapturedMeter[] = [];
const provider: MeterProvider = {
getMeter(name: string): Meter {
const captured: CapturedMeter = { name, instruments: [] };
meters.push(captured);
const meter: Meter = {
createCounter(n) {
const inst: FakeInstrument = { name: n, addCalls: [] };
captured.instruments.push(inst);
return {
add(v: number, a?: Readonly<Record<string, unknown>>) {
inst.addCalls.push({ value: v, attrs: a });
},
} as ReturnType<Meter["createCounter"]>;
},
} as unknown as Meter;
return meter;
},
} as MeterProvider;
return { provider, meters };
}
describe("createGraphiqlInstruments", () => {
it("creates the graphql.ui.requests_total counter on the package meter", {
timeout: 5000,
}, () => {
const { provider, meters } = buildFakeMeterProvider();
const instruments = createGraphiqlInstruments({
meterProvider: provider,
});
expect(meters[0]?.name).toBe("@suaveplan/api-graphql-ui-graphiql");
expect(meters[0]?.instruments.map((i) => i.name)).toEqual([
"graphql.ui.requests_total",
]);
expect(instruments.requestsTotal).toBeDefined();
expect(instruments.meter).toBeDefined();
});
it("falls back to OTel globals when no provider supplied", {
timeout: 5000,
}, () => {
const instruments = createGraphiqlInstruments();
expect(instruments.requestsTotal).toBeDefined();
});
it("UI HTML increments graphql.ui.requests_total", {
timeout: 5000,
}, () => {
const { provider, meters } = buildFakeMeterProvider();
const instruments = createGraphiqlInstruments({
meterProvider: provider,
});
recordUiRequest(instruments);
recordUiRequest(instruments);
const counter = meters[0]?.instruments.find(
(i) => i.name === "graphql.ui.requests_total"
);
expect(counter?.addCalls).toHaveLength(2);
expect(counter?.addCalls[0]?.value).toBe(1);
expect(counter?.addCalls[0]?.attrs).toEqual({ ui: UI_LABEL_VALUE });
});
it("UI_LABEL_VALUE is the literal 'graphiql' so dashboards can sum across UIs", {
timeout: 5000,
}, () => {
expect(UI_LABEL_VALUE).toBe("graphiql");
});
});

View File

@@ -0,0 +1,51 @@
import type { Counter, Meter, MeterProvider } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
/**
* Config consumed by {@link createGraphiqlInstruments}. The provider
* defaults to the OTel global, which falls back to a no-op
* implementation when no SDK is installed — so the plugin works in test
* envs without ceremony.
*/
export interface GraphiqlInstrumentsConfig {
readonly meterProvider?: MeterProvider;
}
/**
* Frozen bundle of every OTel instrument the GraphiQL UI plugin emits.
*
* - `graphql.ui.requests_total` — counter labelled `{ ui: "graphiql" }`
* incremented on each successful HTML response. Asset sub-requests do
* NOT increment this counter (router request metrics cover those).
*/
export interface GraphiqlInstruments {
readonly meter: Meter;
readonly requestsTotal: Counter;
}
const METER_NAME = "@suaveplan/api-graphql-ui-graphiql";
/** UI label attribute attached to every observation. */
export const UI_LABEL_VALUE = "graphiql";
/**
* Build the GraphiQL UI plugin's instrument registry. Returns a frozen
* bundle whose counter is constructed once at plugin-mount time.
*/
export function createGraphiqlInstruments(
config: GraphiqlInstrumentsConfig = {}
): GraphiqlInstruments {
const meterProvider = config.meterProvider ?? metrics.getMeterProvider();
const meter = meterProvider.getMeter(METER_NAME);
const requestsTotal = meter.createCounter("graphql.ui.requests_total");
return Object.freeze({ meter, requestsTotal });
}
/**
* Record a UI HTML request. Always labels the observation with
* `{ ui: "graphiql" }` so dashboards can sum across sibling UI plugins
* (e.g. `api-graphql-ui-altair`).
*/
export function recordUiRequest(instruments: GraphiqlInstruments): void {
instruments.requestsTotal.add(1, { ui: UI_LABEL_VALUE });
}

View File

@@ -0,0 +1,6 @@
export {
type GraphiqlUiOptions,
type GraphiqlUiOptionsInput,
graphiqlUiOptionsSchema,
parseGraphiqlUiOptions,
} from "./options.js";

View File

@@ -0,0 +1,81 @@
# `options`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod-validated configuration surface for `createGraphiqlUi`. Owns
`GraphiqlUiOptions` (resolved shape with every default applied),
`GraphiqlUiOptionsInput` (caller-facing shape where every field is
optional), and `parseGraphiqlUiOptions` (the boundary parser).
The schema is `.strict()` so typos in caller-supplied option keys throw
at parse time rather than silently no-op'ing — empirically the most
common operator mistake when wiring a new UI plugin.
## Features
- Three endpoint paths (`uiEndpoint`, `graphqlEndpoint`,
`subscriptionEndpoint`) — each must start with `/`.
- Theme literal — `"light"` / `"dark"` / `"system"`, default `"system"`.
- `defaultQuery` — pre-fills the editor textarea.
- `defaultVariableEditorOpen` — opens the variables pane on first render.
- `editorOptions` — open passthrough record forwarded to the bundled
bootstrap (CodeMirror options).
## Usage: Basic
```ts
import { parseGraphiqlUiOptions } from "@suaveplan/api-graphql-ui-graphiql";
const options = parseGraphiqlUiOptions({});
// options.uiEndpoint === "/graphql/ui"
```
## Usage: Advanced
```ts
import { parseGraphiqlUiOptions } from "@suaveplan/api-graphql-ui-graphiql";
const options = parseGraphiqlUiOptions({
uiEndpoint: "/api/explorer",
graphqlEndpoint: "/api/gql",
subscriptionEndpoint: "/api/gql/ws",
theme: "dark",
defaultQuery: "{ viewer { id } }",
defaultVariableEditorOpen: true,
editorOptions: { lineWrapping: true, tabSize: 2 },
});
```
## API Reference
- `graphiqlUiOptionsSchema` — strict Zod object with defaults baked in.
- `GraphiqlUiOptions` — `z.infer<typeof graphiqlUiOptionsSchema>`.
- `GraphiqlUiOptionsInput` — `z.input<typeof graphiqlUiOptionsSchema>`.
- `parseGraphiqlUiOptions(input?)` — throws `ZodError` on invalid input;
returns `GraphiqlUiOptions` with every default applied.
## Implementation Notes
The endpoint paths use `.startsWith("/")` rather than a regex so the
error message remains operator-friendly. The schema does NOT validate
whether `subscriptionEndpoint` is reachable — subscription wiring is
the engine adapter's responsibility. Strict mode is on, so a stray
`graphqlEnpdoint` (typo) becomes a hard parse failure.
`GraphiqlUiOptionsInput` carries every field as optional with the
defaults baked in via Zod's `.default(...)`. The factory uses this
shape on its public surface so callers always receive a fully-resolved
`GraphiqlUiOptions` regardless of how many fields they supplied.
The `theme` field intentionally uses a discriminated union of three
literals rather than a string-with-enum because Zod's strict literal
union narrows TypeScript correctly while preserving exhaustiveness
checks at downstream call sites — important for the HTML shell, which
emits a `data-theme="<value>"` attribute. The `editorOptions` record is
typed as `Record<string, unknown>` so it can accept any CodeMirror
configuration without churn on the schema when new editor knobs land
upstream.

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { graphiqlUiOptionsSchema, parseGraphiqlUiOptions } from "./options.js";
describe("parseGraphiqlUiOptions", () => {
it("applies every default when invoked with no input", {
timeout: 5000,
}, () => {
const parsed = parseGraphiqlUiOptions(undefined);
expect(parsed.uiEndpoint).toBe("/graphql/ui");
expect(parsed.graphqlEndpoint).toBe("/graphql");
expect(parsed.subscriptionEndpoint).toBe("/graphql/ws");
expect(parsed.theme).toBe("system");
expect(parsed.defaultQuery).toBe("");
expect(parsed.defaultVariableEditorOpen).toBe(false);
expect(parsed.editorOptions).toEqual({});
});
it("applies every default when invoked with empty object", {
timeout: 5000,
}, () => {
const parsed = parseGraphiqlUiOptions({});
expect(parsed.uiEndpoint).toBe("/graphql/ui");
expect(parsed.theme).toBe("system");
});
it("threads explicit values through unchanged", { timeout: 5000 }, () => {
const parsed = parseGraphiqlUiOptions({
uiEndpoint: "/api/explorer",
graphqlEndpoint: "/api/gql",
subscriptionEndpoint: "/api/gql/ws",
theme: "dark",
defaultQuery: "{ hello }",
defaultVariableEditorOpen: true,
editorOptions: { lineWrapping: true },
});
expect(parsed.uiEndpoint).toBe("/api/explorer");
expect(parsed.graphqlEndpoint).toBe("/api/gql");
expect(parsed.subscriptionEndpoint).toBe("/api/gql/ws");
expect(parsed.theme).toBe("dark");
expect(parsed.defaultQuery).toBe("{ hello }");
expect(parsed.defaultVariableEditorOpen).toBe(true);
expect(parsed.editorOptions).toEqual({ lineWrapping: true });
});
it.each([["light"], ["dark"], ["system"]] as const)("accepts theme=%s", {
timeout: 5000,
}, (theme) => {
const parsed = parseGraphiqlUiOptions({ theme });
expect(parsed.theme).toBe(theme);
});
it("rejects uiEndpoint not starting with '/'", { timeout: 5000 }, () => {
expect(() =>
parseGraphiqlUiOptions({ uiEndpoint: "graphql/ui" })
).toThrow();
});
it("rejects graphqlEndpoint not starting with '/'", {
timeout: 5000,
}, () => {
expect(() =>
parseGraphiqlUiOptions({ graphqlEndpoint: "gql" })
).toThrow();
});
it("rejects subscriptionEndpoint not starting with '/'", {
timeout: 5000,
}, () => {
expect(() =>
parseGraphiqlUiOptions({ subscriptionEndpoint: "ws" })
).toThrow();
});
it("rejects unknown theme literal", { timeout: 5000 }, () => {
expect(() =>
parseGraphiqlUiOptions({ theme: "rainbow" as never })
).toThrow();
});
it("rejects unknown options keys (strict)", { timeout: 5000 }, () => {
expect(() => graphiqlUiOptionsSchema.parse({ wat: 1 })).toThrow();
});
});

View File

@@ -0,0 +1,45 @@
import z from "zod";
/**
* Zod schema for {@link GraphiqlUiOptions} with all defaults baked in.
*
* Endpoint paths MUST start with `/`. The `subscriptionEndpoint` MAY be
* the same as `graphqlEndpoint` when subscriptions are not in use; the
* shell template treats them independently. `theme: "system"` defers
* to the user's `prefers-color-scheme` media query in the browser.
*/
export const graphiqlUiOptionsSchema = z
.object({
uiEndpoint: z
.string()
.startsWith("/", "uiEndpoint must start with '/'")
.default("/graphql/ui"),
graphqlEndpoint: z
.string()
.startsWith("/", "graphqlEndpoint must start with '/'")
.default("/graphql"),
subscriptionEndpoint: z
.string()
.startsWith("/", "subscriptionEndpoint must start with '/'")
.default("/graphql/ws"),
theme: z
.union([z.literal("light"), z.literal("dark"), z.literal("system")])
.default("system"),
defaultQuery: z.string().default(""),
defaultVariableEditorOpen: z.boolean().default(false),
editorOptions: z.record(z.string(), z.unknown()).default({}),
})
.strict();
/** Resolved (post-parse) options shape, with every default applied. */
export type GraphiqlUiOptions = z.infer<typeof graphiqlUiOptionsSchema>;
/** Caller-facing options input — every field optional, defaults applied. */
export type GraphiqlUiOptionsInput = z.input<typeof graphiqlUiOptionsSchema>;
/** Parse + validate, throwing the underlying ZodError on failure. */
export function parseGraphiqlUiOptions(
input: GraphiqlUiOptionsInput | undefined
): GraphiqlUiOptions {
return graphiqlUiOptionsSchema.parse(input ?? {});
}

View File

@@ -0,0 +1,151 @@
import { Router } from "@suaveplan/api-router";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createGraphiqlUi,
GRAPHQL_UI_GRAPHIQL_CODES,
type GraphiqlRouteHandler,
type RouterResponseValue,
} from "../../src/index.js";
/**
* End-to-end integration test.
*
* Wires the GraphiQL UI plugin into an in-memory `Router` alongside a
* minimal POST `/graphql` handler that echoes a canned response, then
* exercises the full request flow:
* 1. GET `/graphql/ui` -> HTML shell (status 200, CSP header, hashed
* asset references embedded).
* 2. GET `/graphql/ui/assets/<hashed-name>` -> bundled JS / CSS
* payload (status 200, immutable cache, no CDN refs).
* 3. POST `/graphql` -> JSON response (the explorer would issue this
* via `fetch` once the user clicks "Run"; here we drive it
* directly so the integration scope stays in-process).
*
* The umbrella Stage-7 plan calls for a Playwright-driven browser test
* once `api-graphql-yoga` is wired. This test stands in until that
* package lands — it pins every contract this plugin is responsible
* for and stays meaningful without a real GraphQL engine.
*/
type ProtocolHandler = (request: {
readonly method: string;
readonly path: string;
readonly body?: string;
}) => RouterResponseValue;
function dispatch(
router: Router,
method: "GET" | "POST",
path: string,
body?: string
): RouterResponseValue {
const match = router.match(method, path);
if (match === null) throw new Error(`no match for ${method} ${path}`);
const handler = match.handler as unknown as
| GraphiqlRouteHandler
| ProtocolHandler;
return handler({ method: match.method, path, body });
}
describe("api-graphql-ui-graphiql — integration", () => {
it("boots end-to-end: shell loads, asset serves, query round-trips", {
timeout: 30000,
}, () => {
const router = new Router({ plugin: "host" });
// Minimal /graphql endpoint — JSON in, JSON out.
const protocolHandler: ProtocolHandler = (req) => {
const parsed: { query?: string } = req.body
? (JSON.parse(req.body) as { query?: string })
: {};
return {
status: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data: { echo: parsed.query ?? null },
}),
};
};
router.post(
"/graphql",
protocolHandler as unknown as Parameters<Router["post"]>[1]
);
createGraphiqlUi({}).register(router);
// 1. Shell loads at /graphql/ui.
const shell = dispatch(router, "GET", "/graphql/ui");
expect(shell.status).toBe(200);
expect(shell.headers["Content-Security-Policy"]).toContain(
"default-src 'self'"
);
expect(shell.body).toContain("<!DOCTYPE html>");
// No external host references.
expect(shell.body).not.toMatch(/https?:\/\//);
// Extract the JS asset filename from the shell.
const jsMatch = shell.body.match(
/\/graphql\/ui\/assets\/(graphiql\.[0-9a-f]{8}\.js)/
);
expect(jsMatch).not.toBeNull();
const jsFilename = jsMatch?.[1] ?? "";
expect(jsFilename.length).toBeGreaterThan(0);
// 2. Asset round-trips.
const asset = dispatch(
router,
"GET",
`/graphql/ui/assets/${jsFilename}`
);
expect(asset.status).toBe(200);
expect(asset.headers["Content-Type"]).toContain(
"application/javascript"
);
expect(asset.headers["Cache-Control"]).toBe(
"public, max-age=31536000, immutable"
);
expect(asset.body.length).toBeGreaterThan(0);
// 3. Query round-trips through the /graphql protocol endpoint
// — exactly what the in-browser explorer does on "Run".
const protocol = dispatch(
router,
"POST",
"/graphql",
JSON.stringify({ query: "{ ping }" })
);
expect(protocol.status).toBe(200);
const decoded = JSON.parse(protocol.body) as {
data?: { echo?: string };
};
expect(decoded.data?.echo).toBe("{ ping }");
});
it("stale-hash asset names resolve during a rolling deploy", {
timeout: 5000,
}, () => {
const router = new Router({ plugin: "host" });
createGraphiqlUi({}).register(router);
const stale = dispatch(
router,
"GET",
"/graphql/ui/assets/graphiql.deadbeef.js"
);
expect(stale.status).toBe(200);
expect(stale.body.length).toBeGreaterThan(0);
});
it("asset endpoint rejects unknown filenames with ASSET_MISSING", {
timeout: 5000,
}, () => {
const router = new Router({ plugin: "host" });
createGraphiqlUi({}).register(router);
try {
dispatch(router, "GET", "/graphql/ui/assets/unknown.deadbeef.png");
throw new Error("expected throw");
} catch (err) {
const code = (err as { code?: string }).code;
expect(code).toBe(GRAPHQL_UI_GRAPHIQL_CODES.ASSET_MISSING);
}
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-graphql-ui-graphiql",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [/^@suaveplan\//, /^@opentelemetry\//, "tslib", "zod"],
},
},
});

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});