Initial Commit
This commit is contained in:
107
packages/api-stack/api/api-graphql-ui-graphiql/README.md
Normal file
107
packages/api-stack/api/api-graphql-ui-graphiql/README.md
Normal 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`).
|
||||
|
||||
90
packages/api-stack/api/api-graphql-ui-graphiql/package.json
Normal file
90
packages/api-stack/api/api-graphql-ui-graphiql/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 } }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
fnv1a32,
|
||||
GRAPHIQL_ASSETS,
|
||||
type GraphiqlAsset,
|
||||
hashedAssetPath,
|
||||
resolveAsset,
|
||||
} from "./assets.js";
|
||||
@@ -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`.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
GRAPHQL_UI_GRAPHIQL_CODES,
|
||||
type GraphqlUiGraphiqlCode,
|
||||
GraphqlUiGraphiqlError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
type CreateGraphiqlUiInput,
|
||||
createGraphiqlUi,
|
||||
type GraphiqlRouteHandler,
|
||||
hashedAssetPath,
|
||||
listGraphiqlAssetNames,
|
||||
type RouterPlugin,
|
||||
type RouterResponseValue,
|
||||
} from "./create-graphiql-ui.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
buildCspHeader,
|
||||
CSP_HEADER_NAME,
|
||||
CSP_HEADER_VALUE,
|
||||
cspAllowsInlineScripts,
|
||||
} from "./csp.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { escapeHtml, renderShell } from "./render-shell.js";
|
||||
@@ -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`.
|
||||
@@ -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("<&">'");
|
||||
});
|
||||
|
||||
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(""<X>"");
|
||||
});
|
||||
|
||||
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}');
|
||||
});
|
||||
});
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
`;
|
||||
}
|
||||
51
packages/api-stack/api/api-graphql-ui-graphiql/src/index.ts
Normal file
51
packages/api-stack/api/api-graphql-ui-graphiql/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createGraphiqlInstruments,
|
||||
type GraphiqlInstruments,
|
||||
type GraphiqlInstrumentsConfig,
|
||||
recordUiRequest,
|
||||
UI_LABEL_VALUE,
|
||||
} from "./instruments.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type GraphiqlUiOptions,
|
||||
type GraphiqlUiOptionsInput,
|
||||
graphiqlUiOptionsSchema,
|
||||
parseGraphiqlUiOptions,
|
||||
} from "./options.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 ?? {});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user