Initial Commit
This commit is contained in:
130
packages/api-stack/api/api-openapi-ui-scalar/README.md
Normal file
130
packages/api-stack/api/api-openapi-ui-scalar/README.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# @suaveplan/api-openapi-ui-scalar — Scalar UI adapter for the Suaveplan OpenAPI plugin system. Exposes createScalarUiHandler() and createPerPluginScalarUiHandler() — factories returning HTTP handlers that serve a self-contained Scalar HTML shell with strict CSP. Default OpenAPI explorer when operators want Scalar; sibling api-openapi-ui-swagger and api-openapi-ui-redoc cover the alternatives.
|
||||
|
||||
Scalar UI adapter for the Suaveplan OpenAPI plugin system.
|
||||
|
||||
`createScalarUiHandler(opts)` returns a frozen handler bundle that
|
||||
serves a self-contained Scalar HTML shell at `/docs/ui/scalar` (or any
|
||||
configured endpoint). The bundle is router-agnostic — host adapters
|
||||
(`api-router-hono`, `api-router-in-memory`, worker bridge) wrap the
|
||||
handler with their native request context. `createPerPluginScalarUiHandler`
|
||||
is the per-plugin variant mounted under `/docs/ui/scalar/<plugin>`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [assets](./src/assets/assets.md) — The assets module owns the in-package bundled Scalar runtime that ships with the package — a small self-contained JS bo…
|
||||
- [assets](./src/assets/assets.md) — The assets module owns the in-package bundled Scalar runtime that ships with the package — a small self-contained JS bo…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code registry for the `@suaveplan/api-openapi-ui-scalar` package. The registry is bui…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code registry for the `@suaveplan/api-openapi-ui-scalar` package. The registry is bui…
|
||||
- [handler](./src/handler/handler.md) — The handler module owns the public `createScalarUiHandler` factory the host calls when `host.openapi.ui = "scalar"`. It…
|
||||
- [handler](./src/handler/handler.md) — The handler module owns the public `createScalarUiHandler` factory the host calls when `host.openapi.ui = "scalar"`. It…
|
||||
- [headers](./src/headers/csp.md) — The headers module builds the `Content-Security-Policy` header value emitted on every HTML response from the Scalar UI…
|
||||
- [csp](./src/headers/csp.md) — The headers module builds the `Content-Security-Policy` header value emitted on every HTML response from the Scalar UI…
|
||||
- [html](./src/html/render-shell.md) — The render-shell module emits the HTML body returned by the Scalar UI handler. It embeds the configured OpenAPI documen…
|
||||
- [render-shell](./src/html/render-shell.md) — The render-shell module emits the HTML body returned by the Scalar UI handler. It embeds the configured OpenAPI documen…
|
||||
- [instruments](./src/instruments/instruments.md) — The instruments module wires the Scalar UI handler's OTel instruments into a frozen bundle constructed once at handler-…
|
||||
- [instruments](./src/instruments/instruments.md) — The instruments module wires the Scalar UI handler's OTel instruments into a frozen bundle constructed once at handler-…
|
||||
- [options](./src/options/options.md) — The options module owns the single Zod schema consumed by every Scalar UI handler factory. It is exposed both directly…
|
||||
- [options](./src/options/options.md) — The options module owns the single Zod schema consumed by every Scalar UI handler factory. It is exposed both directly…
|
||||
- [per-plugin](./src/per-plugin/per-plugin.md) — The per-plugin module exposes `createPerPluginScalarUiHandler`, the variant the host mounts under `/docs/ui/scalar/<plu…
|
||||
- [per-plugin](./src/per-plugin/per-plugin.md) — The per-plugin module exposes `createPerPluginScalarUiHandler`, the variant the host mounts under `/docs/ui/scalar/<plu…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Why opt-in
|
||||
|
||||
Per the design doc (`docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`
|
||||
§7) the API foundation reserves `/docs/ui/<vendor>` for an interactive
|
||||
OpenAPI 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-openapi-ui-scalar` — canonical Scalar (this package).
|
||||
- `@suaveplan/api-openapi-ui-swagger` — Swagger UI alternative.
|
||||
- `@suaveplan/api-openapi-ui-redoc` — ReDoc alternative.
|
||||
|
||||
The OpenAPI emitter (`api-openapi`) does not require a UI plugin — the
|
||||
JSON / YAML document is always available at `/openapi.json` regardless.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createScalarUiHandler } from "@suaveplan/api-openapi-ui-scalar";
|
||||
|
||||
const { handler, options } = createScalarUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
});
|
||||
|
||||
// GET /docs/ui/scalar -> the explorer
|
||||
const response = handler({ method: "GET", path: options.uiEndpoint });
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- `handler` — `createScalarUiHandler` factory and HTTP handler shape.
|
||||
- `per-plugin` — `createPerPluginScalarUiHandler` variant.
|
||||
- `options` — `scalarUiOptionsSchema` + `parseScalarUiOptions`.
|
||||
- `assets` — bundled JS / CSS registry and `resolveAssetUrl` resolver.
|
||||
- `headers` — `buildCspHeader` strict CSP builder.
|
||||
- `html` — `renderShell` HTML shell renderer.
|
||||
- `instruments` — OTel counter wiring.
|
||||
- `error-codes` — `OPENAPI_UI_SCALAR.*` registry.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Option | Default | Notes |
|
||||
|---|---|---|
|
||||
| `openapiUrl` | `/openapi.json` | Embedded into the Scalar config block. |
|
||||
| `uiEndpoint` | `/docs/ui/scalar` | Must start with `/`. |
|
||||
| `title` | `"API Reference"` | HTML-escaped before embedding. |
|
||||
| `theme` | `"default"` | One of 11 built-in Scalar themes. |
|
||||
| `layout` | `"modern"` | `"modern"` / `"classic"`. |
|
||||
| `assetMode` | `"cdn"` | `"cdn"` / `"bundled"`. |
|
||||
| `cdnUrl` | JSDelivr URL | Whitelisted in CSP when `assetMode = "cdn"`. |
|
||||
| `defaultOpenAllTags` | `false` | Expand every tag on load. |
|
||||
| `hideSearch` | `false` | Hide the Scalar search box. |
|
||||
| `darkMode` | `false` | Force dark mode. |
|
||||
| `editorOptions` | `{}` | Open passthrough bag to Scalar. |
|
||||
| `meterProvider` | OTel global | OTel `MeterProvider` for the counter. |
|
||||
|
||||
## Security posture
|
||||
|
||||
- **Strict CSP.** `default-src 'self'; script-src 'self' [<cdn>]; style-src 'self' 'unsafe-inline' [<cdn>]; connect-src 'self' [<api-origin>]`.
|
||||
Inline scripts are NEVER permitted — the Scalar bootstrap is loaded as
|
||||
an external `<script src>` so `'unsafe-inline'` would be a regression.
|
||||
- **Per-request nonce passthrough.** Hosts running nonce-based CSP pass
|
||||
`nonce` on each request and the renderer threads it onto the inline
|
||||
config block.
|
||||
- **Bundled-mode hardening.** `assetMode = "bundled"` keeps every fetch
|
||||
inside the host origin. A missing bundled asset raises
|
||||
`OPENAPI_UI_SCALAR.ASSETS_MISSING` at handler construction — no silent
|
||||
fall-back to CDN.
|
||||
- **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 mid-flight.
|
||||
|
||||
## OTel
|
||||
|
||||
- `openapi.ui.requests_total { ui: "scalar" }` — counter incremented on
|
||||
each successful HTML response.
|
||||
- `openapi.ui.requests_total { ui: "scalar", plugin: "<name>" }` — per-
|
||||
plugin fan-out emitted by the per-plugin handler in addition to the
|
||||
baseline observation.
|
||||
|
||||
## Error codes
|
||||
|
||||
- `OPENAPI_UI_SCALAR.ASSETS_MISSING` — bundled mode without a bundle.
|
||||
- `OPENAPI_UI_SCALAR.INVALID_OPTIONS` — Zod schema rejected the config.
|
||||
- `OPENAPI_UI_SCALAR.ASSET_NOT_REGISTERED` — internal asset lookup miss.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
bunx --bun turbo lint typecheck test:coverage --filter=@suaveplan/api-openapi-ui-scalar
|
||||
```
|
||||
|
||||
100% statements / branches / functions / lines required on touched
|
||||
modules (`provider: istanbul`).
|
||||
|
||||
93
packages/api-stack/api/api-openapi-ui-scalar/package.json
Normal file
93
packages/api-stack/api/api-openapi-ui-scalar/package.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"name": "@suaveplan/api-openapi-ui-scalar",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Stage-8.7 Scalar UI adapter for the Suaveplan OpenAPI plugin foundation. Exposes createScalarUiHandler() and createPerPluginScalarUiHandler() — factories returning HTTP handlers that serve a self-contained Scalar HTML shell with strict CSP. Default OpenAPI explorer when operators want Scalar; sibling api-openapi-ui-swagger and api-openapi-ui-redoc cover the alternatives.",
|
||||
"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"
|
||||
},
|
||||
"./handler": {
|
||||
"types": "./dist/handler/index.d.ts",
|
||||
"default": "./dist/handler/index.js"
|
||||
},
|
||||
"./per-plugin": {
|
||||
"types": "./dist/per-plugin/index.d.ts",
|
||||
"default": "./dist/per-plugin/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/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,92 @@
|
||||
# Scalar bundled-asset registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The assets module owns the in-package bundled Scalar runtime that
|
||||
ships with the package — a small self-contained JS bootstrap and a tiny
|
||||
companion stylesheet — plus the asset-URL resolver consumed by the
|
||||
handler factory. Air-gapped hosts toggle `assetMode = "bundled"` and the
|
||||
shell loads JS / CSS from `<uiEndpoint>/assets/<hash>.<ext>`; hosts on
|
||||
the public internet toggle `assetMode = "cdn"` and the shell loads the
|
||||
upstream `@scalar/api-reference` bundle from JSDelivr. Either way the
|
||||
shell embeds the bootstrap config block at render time so the runtime
|
||||
target is fixed at handler construction.
|
||||
|
||||
## Features
|
||||
|
||||
- `SCALAR_ASSETS` — frozen registry keyed by logical asset name, every
|
||||
entry carrying its bytes, MIME type, and an FNV-1a-32 cache-busting
|
||||
hash.
|
||||
- `hashedAssetPath(name)` — formats the `<stem>.<hash>.<ext>` filename
|
||||
embedded in the rendered shell.
|
||||
- `resolveAsset(filename)` — reverse lookup tolerating stale hashes
|
||||
(rolling deploy) and missing-hash bare filenames.
|
||||
- `resolveAssetUrl({ mode, cdnUrl, uiEndpoint, probe })` — returns the
|
||||
Scalar runtime URL the shell should embed. `bundled` + missing asset
|
||||
raises `OPENAPI_UI_SCALAR.ASSETS_MISSING`.
|
||||
- `defaultBundledAssetProbe` — checks the in-package registry is
|
||||
non-empty (default behaviour); tests inject a stub to simulate the
|
||||
stripped-bundle case.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { resolveAssetUrl } from "@suaveplan/api-openapi-ui-scalar/assets";
|
||||
|
||||
const runtime = resolveAssetUrl({
|
||||
mode: "cdn",
|
||||
cdnUrl: "https://cdn.jsdelivr.net/npm/@scalar/api-reference",
|
||||
uiEndpoint: "/docs/ui/scalar",
|
||||
});
|
||||
console.log(runtime.url);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
resolveAssetUrl,
|
||||
type BundledAssetProbe,
|
||||
} from "@suaveplan/api-openapi-ui-scalar/assets";
|
||||
|
||||
const stubbedProbe: BundledAssetProbe = { hasBundle: () => false };
|
||||
|
||||
// Simulate the air-gapped "asset stripped from dist" failure case —
|
||||
// `resolveAssetUrl` throws OPENAPI_UI_SCALAR.ASSETS_MISSING.
|
||||
try {
|
||||
resolveAssetUrl({
|
||||
mode: "bundled",
|
||||
cdnUrl: "",
|
||||
uiEndpoint: "/docs/ui/scalar",
|
||||
probe: stubbedProbe,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("bundled asset is missing", err);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ScalarAsset` interface — `{ name, content, contentType, hash }`.
|
||||
- `SCALAR_ASSETS` `ReadonlyMap<string, ScalarAsset>` — frozen registry.
|
||||
- `fnv1a32(input)` — 8-char hex FNV-1a-32 of the asset bytes.
|
||||
- `hashedAssetPath(name)` — throws `ASSET_NOT_REGISTERED` when missing.
|
||||
- `resolveAsset(filename)` — returns the asset entry or throws
|
||||
`ASSET_NOT_REGISTERED`.
|
||||
- `resolveAssetUrl(input)` — returns `{ mode, url }`; throws
|
||||
`ASSETS_MISSING` in bundled mode without a bundle.
|
||||
- `listScalarAssetNames()` — listing of registered names for tests.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bundled JS bootstrap is intentionally minimal — it parses the
|
||||
`<script id="scalar-config" type="application/json">` block and renders
|
||||
a status line + path listing so air-gapped hosts have a functional
|
||||
fallback. Operators that need the full Scalar React UI install the
|
||||
upstream `@scalar/api-reference` and overwrite `dist/assets/scalar.js`
|
||||
at build time; the FNV-1a hash refreshes automatically. The hash is NOT
|
||||
security-relevant — it exists only to invalidate browser caches.
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
OPENAPI_UI_SCALAR_CODES,
|
||||
OpenapiUiScalarError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
type BundledAssetProbe,
|
||||
defaultBundledAssetProbe,
|
||||
fnv1a32,
|
||||
hashedAssetPath,
|
||||
listScalarAssetNames,
|
||||
resolveAsset,
|
||||
resolveAssetUrl,
|
||||
SCALAR_ASSETS,
|
||||
} from "./assets.js";
|
||||
|
||||
describe("assets", () => {
|
||||
it("cdn mode returns documented CDN URL", { timeout: 5000 }, () => {
|
||||
const result = resolveAssetUrl({
|
||||
mode: "cdn",
|
||||
cdnUrl: "https://cdn.jsdelivr.net/npm/@scalar/api-reference",
|
||||
uiEndpoint: "/docs/ui/scalar",
|
||||
});
|
||||
expect(result.mode).toBe("cdn");
|
||||
expect(result.url).toBe(
|
||||
"https://cdn.jsdelivr.net/npm/@scalar/api-reference"
|
||||
);
|
||||
});
|
||||
|
||||
it("bundled mode without asset raises ASSETS_MISSING", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const empty: BundledAssetProbe = { hasBundle: () => false };
|
||||
try {
|
||||
resolveAssetUrl({
|
||||
mode: "bundled",
|
||||
cdnUrl: "",
|
||||
uiEndpoint: "/docs/ui/scalar",
|
||||
probe: empty,
|
||||
});
|
||||
throw new Error("expected ASSETS_MISSING");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OpenapiUiScalarError);
|
||||
expect((err as OpenapiUiScalarError).code).toBe(
|
||||
OPENAPI_UI_SCALAR_CODES.ASSETS_MISSING
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("bundled mode returns hashed asset path", { timeout: 5000 }, () => {
|
||||
const result = resolveAssetUrl({
|
||||
mode: "bundled",
|
||||
cdnUrl: "",
|
||||
uiEndpoint: "/docs/ui/scalar",
|
||||
});
|
||||
expect(result.mode).toBe("bundled");
|
||||
expect(result.url.startsWith("/docs/ui/scalar/assets/scalar.")).toBe(
|
||||
true
|
||||
);
|
||||
expect(result.url.endsWith(".js")).toBe(true);
|
||||
});
|
||||
|
||||
it("fnv1a32 is deterministic and 8 hex chars", { timeout: 5000 }, () => {
|
||||
const hash = fnv1a32("hello");
|
||||
expect(hash).toMatch(/^[0-9a-f]{8}$/);
|
||||
expect(fnv1a32("hello")).toBe(hash);
|
||||
expect(fnv1a32("world")).not.toBe(hash);
|
||||
});
|
||||
|
||||
it("hashedAssetPath throws ASSET_NOT_REGISTERED for unknown name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
hashedAssetPath("missing.js");
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OpenapiUiScalarError);
|
||||
expect((err as OpenapiUiScalarError).code).toBe(
|
||||
OPENAPI_UI_SCALAR_CODES.ASSET_NOT_REGISTERED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolveAsset accepts bare name, hashed, and stale hash", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bare = resolveAsset("scalar.js");
|
||||
expect(bare.name).toBe("scalar.js");
|
||||
const hashed = resolveAsset(hashedAssetPath("scalar.js"));
|
||||
expect(hashed.name).toBe("scalar.js");
|
||||
const stale = resolveAsset("scalar.deadbeef.js");
|
||||
expect(stale.name).toBe("scalar.js");
|
||||
});
|
||||
|
||||
it("resolveAsset throws ASSET_NOT_REGISTERED for unknown stem", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
resolveAsset("unknown.xyz");
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OpenapiUiScalarError);
|
||||
expect((err as OpenapiUiScalarError).code).toBe(
|
||||
OPENAPI_UI_SCALAR_CODES.ASSET_NOT_REGISTERED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("every registered asset has a dot-separated extension", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const asset of SCALAR_ASSETS.values()) {
|
||||
expect(asset.name.lastIndexOf(".")).toBeGreaterThanOrEqual(0);
|
||||
expect(asset.hash).toMatch(/^[0-9a-f]{8}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("listScalarAssetNames mirrors the registry keys", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(listScalarAssetNames().sort()).toEqual(
|
||||
Array.from(SCALAR_ASSETS.keys()).sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("defaultBundledAssetProbe reports hasBundle = true", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(defaultBundledAssetProbe.hasBundle()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
OPENAPI_UI_SCALAR_CODES,
|
||||
OpenapiUiScalarError,
|
||||
} 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 ScalarAsset {
|
||||
/** 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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundled Scalar bootstrap. This is a self-contained loader that runs in
|
||||
* the browser, reads the `<script id="scalar-config" type="application/json">`
|
||||
* config block, dispatches the OpenAPI document URL into a minimal
|
||||
* fetch-and-render shell, and exposes a global initialiser the upstream
|
||||
* Scalar package can replace at install-time. The bundle does NOT inline
|
||||
* the upstream Scalar React app — air-gapped hosts that need the full
|
||||
* Scalar UI install the upstream `@scalar/api-reference` and overwrite
|
||||
* `dist/assets/scalar.js` at build time.
|
||||
*/
|
||||
const SCALAR_JS = `(function(){
|
||||
"use strict";
|
||||
var node = document.getElementById("scalar-config");
|
||||
if (node === null) return;
|
||||
var raw = node.textContent || "{}";
|
||||
var cfg;
|
||||
try { cfg = JSON.parse(raw); } catch (err) { cfg = {}; }
|
||||
var root = document.getElementById("scalar-root");
|
||||
if (root === null) return;
|
||||
root.setAttribute("data-openapi-url", cfg.openapiUrl || "");
|
||||
root.setAttribute("data-theme", cfg.theme || "default");
|
||||
root.setAttribute("data-layout", cfg.layout || "modern");
|
||||
root.setAttribute("data-ready", "true");
|
||||
var status = document.getElementById("scalar-status");
|
||||
if (status === null) return;
|
||||
status.textContent = "loading…";
|
||||
fetch(cfg.openapiUrl, {
|
||||
method: "GET",
|
||||
headers: { "Accept": "application/json" }
|
||||
}).then(function(r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
}).then(function(doc) {
|
||||
status.textContent = "OpenAPI " + (doc.openapi || doc.swagger || "?") +
|
||||
" — " + ((doc.info && doc.info.title) || "(untitled)") +
|
||||
" v" + ((doc.info && doc.info.version) || "?");
|
||||
var list = document.getElementById("scalar-paths");
|
||||
if (list === null) return;
|
||||
var paths = doc.paths || {};
|
||||
Object.keys(paths).sort().forEach(function(p) {
|
||||
var li = document.createElement("li");
|
||||
li.textContent = p;
|
||||
list.appendChild(li);
|
||||
});
|
||||
}).catch(function(err) {
|
||||
status.textContent = "error: " + (err && err.message ? err.message : err);
|
||||
});
|
||||
})();
|
||||
`;
|
||||
|
||||
/**
|
||||
* Scalar stylesheet. Kept tiny and self-contained so the strict CSP
|
||||
* (`style-src 'self'`) is enough — no `'unsafe-inline'` needed.
|
||||
*/
|
||||
const SCALAR_CSS = `
|
||||
body { margin: 0; font-family: system-ui, sans-serif; background: #fff; color: #111; }
|
||||
body[data-theme="moon"], body[data-theme="deepSpace"] { background: #0d1117; color: #c9d1d9; }
|
||||
.scalar-shell { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
.scalar-header { padding: 12px 16px; border-bottom: 1px solid #e5e7eb; }
|
||||
.scalar-body { flex: 1; padding: 16px; }
|
||||
.scalar-paths { font-family: ui-monospace, monospace; }
|
||||
.scalar-status { color: #475569; font-style: italic; }
|
||||
`;
|
||||
|
||||
function buildAsset(
|
||||
name: string,
|
||||
content: string,
|
||||
contentType: string
|
||||
): ScalarAsset {
|
||||
return {
|
||||
name,
|
||||
content,
|
||||
contentType,
|
||||
hash: fnv1a32(content),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen asset map shipped with this package. Lookup is by logical
|
||||
* name; the HTML shell embeds `<asset-name>.<hash>.<ext>` so the cache
|
||||
* key changes whenever the asset payload changes.
|
||||
*/
|
||||
export const SCALAR_ASSETS: ReadonlyMap<string, ScalarAsset> = new Map([
|
||||
[
|
||||
"scalar.js",
|
||||
buildAsset(
|
||||
"scalar.js",
|
||||
SCALAR_JS,
|
||||
"application/javascript; charset=utf-8"
|
||||
),
|
||||
],
|
||||
[
|
||||
"scalar.css",
|
||||
buildAsset("scalar.css", SCALAR_CSS, "text/css; charset=utf-8"),
|
||||
],
|
||||
]);
|
||||
|
||||
interface AssetNameParts {
|
||||
readonly stem: string;
|
||||
readonly ext: string;
|
||||
}
|
||||
|
||||
function splitAssetName(name: string): AssetNameParts {
|
||||
// Asset registry is closed — every entry carries `.<ext>` so
|
||||
// `lastIndexOf(".")` is guaranteed ≥ 0 at runtime. Asserted in tests.
|
||||
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}`;
|
||||
}
|
||||
|
||||
/** Build the hashed filename embedded in the HTML shell. */
|
||||
export function hashedAssetPath(name: string): string {
|
||||
const asset = SCALAR_ASSETS.get(name);
|
||||
if (asset === undefined) {
|
||||
throw new OpenapiUiScalarError(
|
||||
OPENAPI_UI_SCALAR_CODES.ASSET_NOT_REGISTERED,
|
||||
`Bundled asset "${name}" is not registered in SCALAR_ASSETS`,
|
||||
{ context: { name } }
|
||||
);
|
||||
}
|
||||
return formatHashed(asset.name, asset.hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a hashed filename (as served at `<uiEndpoint>/assets/<filename>`)
|
||||
* back to its asset entry, or throw `ASSET_NOT_REGISTERED` 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_NOT_REGISTERED`.
|
||||
*/
|
||||
export function resolveAsset(filename: string): ScalarAsset {
|
||||
for (const asset of SCALAR_ASSETS.values()) {
|
||||
if (filename === asset.name) return asset;
|
||||
if (filename === hashedAssetPath(asset.name)) return asset;
|
||||
const { stem, ext } = splitAssetName(asset.name);
|
||||
const prefix = `${stem}.`;
|
||||
if (filename.startsWith(prefix) && filename.endsWith(ext)) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
throw new OpenapiUiScalarError(
|
||||
OPENAPI_UI_SCALAR_CODES.ASSET_NOT_REGISTERED,
|
||||
`Bundled asset "${filename}" is not registered`,
|
||||
{ context: { filename } }
|
||||
);
|
||||
}
|
||||
|
||||
/** Internal probe used by the asset-mode resolver. */
|
||||
export interface BundledAssetProbe {
|
||||
readonly hasBundle: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default bundled-asset probe — the registry above is always non-empty in
|
||||
* the shipped build, so this returns `true`. Tests override the probe to
|
||||
* simulate the air-gapped "asset stripped from dist" failure case.
|
||||
*/
|
||||
export const defaultBundledAssetProbe: BundledAssetProbe = Object.freeze({
|
||||
hasBundle: () => SCALAR_ASSETS.size > 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolved Scalar runtime asset URL — either the upstream CDN URL or the
|
||||
* bundled-shell path served by this package under `<uiEndpoint>/assets/`.
|
||||
*
|
||||
* In `"bundled"` mode the probe MUST report a present bundle; otherwise
|
||||
* this raises `OPENAPI_UI_SCALAR_ASSETS_MISSING` so air-gapped hosts fail
|
||||
* fast at handler construction rather than at first request.
|
||||
*/
|
||||
export interface ResolvedAssetUrl {
|
||||
readonly mode: "cdn" | "bundled";
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface ResolveAssetUrlInput {
|
||||
readonly mode: "cdn" | "bundled";
|
||||
readonly cdnUrl: string;
|
||||
readonly uiEndpoint: string;
|
||||
readonly probe?: BundledAssetProbe;
|
||||
}
|
||||
|
||||
export function resolveAssetUrl(input: ResolveAssetUrlInput): ResolvedAssetUrl {
|
||||
if (input.mode === "cdn") {
|
||||
return { mode: "cdn", url: input.cdnUrl };
|
||||
}
|
||||
const probe = input.probe ?? defaultBundledAssetProbe;
|
||||
if (!probe.hasBundle()) {
|
||||
throw new OpenapiUiScalarError(
|
||||
OPENAPI_UI_SCALAR_CODES.ASSETS_MISSING,
|
||||
"Scalar bundled asset is not present — install the upstream @scalar/api-reference runtime or switch host.openapi.uiAssets to 'cdn'",
|
||||
{ context: { mode: input.mode, uiEndpoint: input.uiEndpoint } }
|
||||
);
|
||||
}
|
||||
return {
|
||||
mode: "bundled",
|
||||
url: `${input.uiEndpoint}/assets/${hashedAssetPath("scalar.js")}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Listing of registered asset names — used by the assets test. */
|
||||
export function listScalarAssetNames(): readonly string[] {
|
||||
return Array.from(SCALAR_ASSETS.keys());
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
type BundledAssetProbe,
|
||||
defaultBundledAssetProbe,
|
||||
fnv1a32,
|
||||
hashedAssetPath,
|
||||
listScalarAssetNames,
|
||||
type ResolveAssetUrlInput,
|
||||
type ResolvedAssetUrl,
|
||||
resolveAsset,
|
||||
resolveAssetUrl,
|
||||
SCALAR_ASSETS,
|
||||
type ScalarAsset,
|
||||
} from "./assets.js";
|
||||
@@ -0,0 +1,84 @@
|
||||
# `OPENAPI_UI_SCALAR.*` error-code registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the stable error-code registry for the
|
||||
`@suaveplan/api-openapi-ui-scalar` package. The registry is built via
|
||||
`defineCodes` so every key projects to a frozen `OPENAPI_UI_SCALAR.<NAME>`
|
||||
string the rest of the suite (logger, error bus, OTel attributes) can
|
||||
discriminate on. `OpenapiUiScalarError` extends the shared
|
||||
`SuaveplanError` base so call sites can branch on
|
||||
`instanceof OpenapiUiScalarError` or — more durably — on `error.code`.
|
||||
|
||||
Each code maps to a single production throw site that is exercised in the
|
||||
test suite by a real failure path, not a manual rethrow.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `OPENAPI_UI_SCALAR_CODES` registry with three stable keys:
|
||||
`ASSETS_MISSING`, `INVALID_OPTIONS`, `ASSET_NOT_REGISTERED`.
|
||||
- Typed `OpenapiUiScalarCode` union derived from the registry values.
|
||||
- `OpenapiUiScalarError` subclass carrying the typed `code` + `scalarCode`
|
||||
twin so consumers can pattern-match without a runtime `as` cast.
|
||||
- Cause chain + context bag honoured via the shared base — useful for
|
||||
wrapping the underlying `ZodError` on `INVALID_OPTIONS`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
OPENAPI_UI_SCALAR_CODES,
|
||||
OpenapiUiScalarError,
|
||||
} from "@suaveplan/api-openapi-ui-scalar/error-codes";
|
||||
|
||||
throw new OpenapiUiScalarError(
|
||||
OPENAPI_UI_SCALAR_CODES.ASSETS_MISSING,
|
||||
"Scalar bundled asset is not present"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
OPENAPI_UI_SCALAR_CODES,
|
||||
OpenapiUiScalarError,
|
||||
} from "@suaveplan/api-openapi-ui-scalar/error-codes";
|
||||
|
||||
try {
|
||||
parseHostConfig(input);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof OpenapiUiScalarError &&
|
||||
err.code === OPENAPI_UI_SCALAR_CODES.INVALID_OPTIONS
|
||||
) {
|
||||
// Surface the underlying ZodError issues to the operator console.
|
||||
logger.error("Scalar UI host config rejected", {
|
||||
cause: err.cause,
|
||||
issues: err.context?.issues,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `OPENAPI_UI_SCALAR_CODES` — frozen object with the three code keys.
|
||||
- `OpenapiUiScalarCode` — typed union over the code values.
|
||||
- `OpenapiUiScalarError` — `SuaveplanError<OpenapiUiScalarCode>` subclass.
|
||||
Constructor signature: `(code, message, options?)`. `options` carries
|
||||
`cause` and `context` per the shared error contract.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is intentionally closed — adding a new code requires an
|
||||
OpenSpec change so the audit + dashboards (`openapi_ui_scalar.errors_total`
|
||||
labels) get refreshed in lockstep. The `error-codes.test.ts` conformance
|
||||
file walks every registry entry and asserts a corresponding production
|
||||
throw site lives in the package, so dead codes can never accumulate.
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
OPENAPI_UI_SCALAR_CODES,
|
||||
OpenapiUiScalarError,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("OPENAPI_UI_SCALAR codes", () => {
|
||||
it("exposes the three stable codes", { timeout: 5000 }, () => {
|
||||
expect(OPENAPI_UI_SCALAR_CODES.ASSETS_MISSING).toBe(
|
||||
"OPENAPI_UI_SCALAR.ASSETS_MISSING"
|
||||
);
|
||||
expect(OPENAPI_UI_SCALAR_CODES.INVALID_OPTIONS).toBe(
|
||||
"OPENAPI_UI_SCALAR.INVALID_OPTIONS"
|
||||
);
|
||||
expect(OPENAPI_UI_SCALAR_CODES.ASSET_NOT_REGISTERED).toBe(
|
||||
"OPENAPI_UI_SCALAR.ASSET_NOT_REGISTERED"
|
||||
);
|
||||
});
|
||||
|
||||
it("error carries the code in both fields", { timeout: 5000 }, () => {
|
||||
const err = new OpenapiUiScalarError(
|
||||
OPENAPI_UI_SCALAR_CODES.ASSETS_MISSING,
|
||||
"missing",
|
||||
{ context: { mode: "bundled" } }
|
||||
);
|
||||
expect(err).toBeInstanceOf(OpenapiUiScalarError);
|
||||
expect(err.code).toBe(OPENAPI_UI_SCALAR_CODES.ASSETS_MISSING);
|
||||
expect(err.scalarCode).toBe(OPENAPI_UI_SCALAR_CODES.ASSETS_MISSING);
|
||||
expect(err.name).toBe("OpenapiUiScalarError");
|
||||
expect(err.context?.mode).toBe("bundled");
|
||||
});
|
||||
|
||||
it("supports the cause chain on the shared base", { timeout: 5000 }, () => {
|
||||
const root = new Error("root");
|
||||
const err = new OpenapiUiScalarError(
|
||||
OPENAPI_UI_SCALAR_CODES.INVALID_OPTIONS,
|
||||
"boom",
|
||||
{ cause: root }
|
||||
);
|
||||
expect(err.cause).toBe(root);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Frozen `OPENAPI_UI_SCALAR.*` error-code registry for the Scalar UI
|
||||
* adapter. Every code listed here is thrown by a production path in this
|
||||
* package and verified by `error-codes.test.ts` walking the registry.
|
||||
*
|
||||
* - `ASSETS_MISSING` — `host.openapi.uiAssets = "bundled"` was selected
|
||||
* but no bundled Scalar runtime exists under `dist/assets/`. Raised at
|
||||
* handler construction so an air-gapped host fails fast at boot rather
|
||||
* than at first request.
|
||||
* - `INVALID_OPTIONS` — the `ScalarUiOptions` Zod schema rejected the
|
||||
* caller-supplied configuration. Wrapped on top of the underlying
|
||||
* `ZodError` (`.cause`) so callers can inspect the exact field failure.
|
||||
* - `ASSET_NOT_REGISTERED` — internal asset lookup mis-fire for the
|
||||
* bundled-asset registry; surfaced when a callsite asks for an asset
|
||||
* name not present in `SCALAR_ASSETS`.
|
||||
*/
|
||||
export const OPENAPI_UI_SCALAR_CODES = defineCodes("OPENAPI_UI_SCALAR", [
|
||||
"ASSETS_MISSING",
|
||||
"INVALID_OPTIONS",
|
||||
"ASSET_NOT_REGISTERED",
|
||||
] as const);
|
||||
|
||||
/** Union of every code resolved through {@link OPENAPI_UI_SCALAR_CODES}. */
|
||||
export type OpenapiUiScalarCode =
|
||||
(typeof OPENAPI_UI_SCALAR_CODES)[keyof typeof OPENAPI_UI_SCALAR_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the api-openapi-ui-scalar package. Extends
|
||||
* {@link SuaveplanError} so callers can discriminate via
|
||||
* `error instanceof OpenapiUiScalarError` or the stable `error.code`.
|
||||
*/
|
||||
export class OpenapiUiScalarError extends SuaveplanError<OpenapiUiScalarCode> {
|
||||
public readonly scalarCode: OpenapiUiScalarCode;
|
||||
|
||||
public constructor(
|
||||
code: OpenapiUiScalarCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "OpenapiUiScalarError";
|
||||
this.scalarCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
OPENAPI_UI_SCALAR_CODES,
|
||||
type OpenapiUiScalarCode,
|
||||
OpenapiUiScalarError,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,91 @@
|
||||
# `createScalarUiHandler` — Scalar HTTP handler factory
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The handler module owns the public `createScalarUiHandler` factory the
|
||||
host calls when `host.openapi.ui = "scalar"`. It validates the
|
||||
caller-supplied options through the Zod schema, eagerly resolves the
|
||||
Scalar runtime asset URL (CDN or bundled), constructs the OTel
|
||||
instrument bundle, and returns a frozen `ScalarUiHandlerBundle` whose
|
||||
`handler` field is the actual HTTP handler callable. The bundle also
|
||||
exposes an `assetHandler` for the bundled-mode `<uiEndpoint>/assets/`
|
||||
sub-route the host mounts alongside the main shell.
|
||||
|
||||
## Features
|
||||
|
||||
- Eager validation — invalid options or missing bundled assets fail at
|
||||
handler construction rather than at first request.
|
||||
- Strict CSP — `Content-Security-Policy` header value is derived once at
|
||||
construction time so request handling never has to recompute it. The
|
||||
policy widens to whitelist the CDN origin only when `assetMode = "cdn"`.
|
||||
- Per-request CSP nonce passthrough — the handler accepts an optional
|
||||
`nonce` on each request which the renderer threads onto the inline
|
||||
`<script>` blocks.
|
||||
- Idempotent — repeated handler invocations on the same request input
|
||||
return byte-identical body / headers.
|
||||
- OTel — every successful HTML response increments the
|
||||
`openapi.ui.requests_total` counter with `{ ui: "scalar" }`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createScalarUiHandler } from "@suaveplan/api-openapi-ui-scalar/handler";
|
||||
|
||||
const { handler } = createScalarUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
});
|
||||
|
||||
const response = handler({ method: "GET", path: "/docs/ui/scalar" });
|
||||
console.log(response.status); // 200
|
||||
console.log(response.headers["Content-Security-Policy"]);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createScalarUiHandler } from "@suaveplan/api-openapi-ui-scalar/handler";
|
||||
|
||||
const bundle = createScalarUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
theme: "deepSpace",
|
||||
layout: "classic",
|
||||
assetMode: "bundled",
|
||||
});
|
||||
|
||||
// Per-request nonce — wire into your edge / Hono / Worker middleware.
|
||||
const html = bundle.handler({
|
||||
method: "GET",
|
||||
path: "/docs/ui/scalar",
|
||||
nonce: crypto.randomUUID(),
|
||||
});
|
||||
|
||||
// Bundled-mode hosts ALSO mount the asset handler at
|
||||
// `<uiEndpoint>/assets/:filename`.
|
||||
const js = bundle.assetHandler({
|
||||
method: "GET",
|
||||
path: `${bundle.options.uiEndpoint}/assets/scalar.js`,
|
||||
});
|
||||
console.log(js.headers["Content-Type"]); // "application/javascript; charset=utf-8"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CreateScalarUiHandlerInput` — `ScalarUiOptionsInput` plus
|
||||
`meterProvider?` and `bundledAssetProbe?` DI hooks.
|
||||
- `createScalarUiHandler(input)` — `ScalarUiHandlerBundle`.
|
||||
- `ScalarUiHandlerBundle` — `{ name, version, options, instruments,
|
||||
runtimeUrl, handler, assetHandler }` (frozen).
|
||||
- `ScalarUiHandler` — `(request) => ScalarHttpResponse`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory pre-computes the CSP header value, the resolved runtime
|
||||
URL, and the OTel instrument bundle so request handling is a constant-
|
||||
time render + counter-increment. Mount-time validation catches every
|
||||
operator-config failure mode before the host accepts traffic. Errors
|
||||
raised here carry the stable `OPENAPI_UI_SCALAR.*` codes so host
|
||||
boot-up can discriminate without unpacking the underlying Zod issues.
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { Counter, Meter, MeterProvider } from "@opentelemetry/api";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { BundledAssetProbe } from "../assets/assets.js";
|
||||
import { createScalarUiHandler } from "./handler.js";
|
||||
|
||||
describe("createScalarUiHandler", () => {
|
||||
it("handler returns HTML referencing openapiUrl", { timeout: 5000 }, () => {
|
||||
const bundle = createScalarUiHandler({ openapiUrl: "/openapi.json" });
|
||||
const response = bundle.handler({
|
||||
method: "GET",
|
||||
path: "/docs/ui/scalar",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers["Content-Type"]).toBe(
|
||||
"text/html; charset=utf-8"
|
||||
);
|
||||
// The OpenAPI URL appears in the Scalar configuration block.
|
||||
const occurrences = response.body.split("/openapi.json").length - 1;
|
||||
expect(occurrences).toBe(1);
|
||||
});
|
||||
|
||||
it("emits strict CSP — never permits inline scripts", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { handler } = createScalarUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
assetMode: "cdn",
|
||||
});
|
||||
const response = handler({ method: "GET", path: "/docs/ui/scalar" });
|
||||
const csp = response.headers["Content-Security-Policy"];
|
||||
expect(csp).toBeDefined();
|
||||
expect(csp).not.toContain("script-src 'self' 'unsafe-inline'");
|
||||
expect(response.headers["X-Content-Type-Options"]).toBe("nosniff");
|
||||
expect(response.headers["Referrer-Policy"]).toBe("no-referrer");
|
||||
});
|
||||
|
||||
it("cdn mode whitelists the CDN origin in CSP", { timeout: 5000 }, () => {
|
||||
const { handler } = createScalarUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
assetMode: "cdn",
|
||||
cdnUrl: "https://cdn.example.com/scalar/api-reference",
|
||||
});
|
||||
const response = handler({ method: "GET", path: "/docs/ui/scalar" });
|
||||
const csp = response.headers["Content-Security-Policy"] ?? "";
|
||||
expect(csp).toContain("https://cdn.example.com");
|
||||
});
|
||||
|
||||
it("bundled mode keeps CSP origin-restricted (no cdn host)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { handler } = createScalarUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
assetMode: "bundled",
|
||||
});
|
||||
const response = handler({ method: "GET", path: "/docs/ui/scalar" });
|
||||
const csp = response.headers["Content-Security-Policy"] ?? "";
|
||||
expect(csp).not.toContain("cdn.jsdelivr.net");
|
||||
});
|
||||
|
||||
it("embeds caller-supplied nonce on inline script tag", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { handler } = createScalarUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
});
|
||||
const response = handler({
|
||||
method: "GET",
|
||||
path: "/docs/ui/scalar",
|
||||
nonce: "n-1234567",
|
||||
});
|
||||
expect(response.body).toContain('nonce="n-1234567"');
|
||||
});
|
||||
|
||||
it("is idempotent across requests", { timeout: 5000 }, () => {
|
||||
const { handler } = createScalarUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
});
|
||||
const a = handler({ method: "GET", path: "/docs/ui/scalar" });
|
||||
const b = handler({ method: "GET", path: "/docs/ui/scalar" });
|
||||
expect(a.body).toBe(b.body);
|
||||
expect(a.headers).toEqual(b.headers);
|
||||
});
|
||||
|
||||
it("openapiUrl carrying < is JSON-escaped inside the config block", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { handler } = createScalarUiHandler({
|
||||
openapiUrl: "/openapi.json?q=<script>",
|
||||
});
|
||||
const { body } = handler({
|
||||
method: "GET",
|
||||
path: "/docs/ui/scalar",
|
||||
});
|
||||
expect(body).not.toContain("?q=<script>");
|
||||
expect(body).toContain("\\u003cscript>");
|
||||
});
|
||||
|
||||
it("assetHandler serves bundled JS with cache headers", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bundle = createScalarUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
assetMode: "bundled",
|
||||
});
|
||||
const response = bundle.assetHandler({
|
||||
method: "GET",
|
||||
path: `${bundle.options.uiEndpoint}/assets/scalar.js`,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers["Content-Type"]).toBe(
|
||||
"application/javascript; charset=utf-8"
|
||||
);
|
||||
expect(response.headers["Cache-Control"]).toBe(
|
||||
"public, max-age=31536000, immutable"
|
||||
);
|
||||
});
|
||||
|
||||
it("assetHandler tolerates filename without prefix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bundle = createScalarUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
});
|
||||
const response = bundle.assetHandler({
|
||||
method: "GET",
|
||||
path: "scalar.css",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers["Content-Type"]).toBe(
|
||||
"text/css; charset=utf-8"
|
||||
);
|
||||
});
|
||||
|
||||
it("assetHandler resolves trailing segment of any path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bundle = createScalarUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
});
|
||||
const response = bundle.assetHandler({
|
||||
method: "GET",
|
||||
path: "/some/unrelated/path/scalar.js",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers["Content-Type"]).toBe(
|
||||
"application/javascript; charset=utf-8"
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts no-arg invocation (defaults everywhere)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bundle = createScalarUiHandler();
|
||||
expect(bundle.options.openapiUrl).toBe("/openapi.json");
|
||||
});
|
||||
|
||||
it("exposes name + version + parsed options", { timeout: 5000 }, () => {
|
||||
const bundle = createScalarUiHandler({ openapiUrl: "/openapi.json" });
|
||||
expect(bundle.name).toBe("@suaveplan/api-openapi-ui-scalar");
|
||||
expect(bundle.version).toBe("0.1.0");
|
||||
expect(bundle.options.uiEndpoint).toBe("/docs/ui/scalar");
|
||||
expect(bundle.runtimeUrl.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("absolute openapiUrl threads origin into connect-src", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { handler } = createScalarUiHandler({
|
||||
openapiUrl: "https://api.example.com/openapi.json",
|
||||
});
|
||||
const response = handler({
|
||||
method: "GET",
|
||||
path: "/docs/ui/scalar",
|
||||
});
|
||||
const csp = response.headers["Content-Security-Policy"] ?? "";
|
||||
expect(csp).toContain("connect-src 'self' https://api.example.com");
|
||||
});
|
||||
|
||||
it("honours caller-supplied bundledAssetProbe + meterProvider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const observations: Array<{
|
||||
value: number;
|
||||
attributes: Record<string, unknown>;
|
||||
}> = [];
|
||||
const counter = {
|
||||
add(value: number, attributes?: Record<string, unknown>): void {
|
||||
observations.push({
|
||||
value,
|
||||
attributes: { ...(attributes ?? {}) },
|
||||
});
|
||||
},
|
||||
} as unknown as Counter;
|
||||
const meter = {
|
||||
createCounter(): Counter {
|
||||
return counter;
|
||||
},
|
||||
} as unknown as Meter;
|
||||
const meterProvider = {
|
||||
getMeter(): Meter {
|
||||
return meter;
|
||||
},
|
||||
} as unknown as MeterProvider;
|
||||
const probe: BundledAssetProbe = { hasBundle: () => true };
|
||||
const bundle = createScalarUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
assetMode: "bundled",
|
||||
meterProvider,
|
||||
bundledAssetProbe: probe,
|
||||
});
|
||||
bundle.handler({ method: "GET", path: "/docs/ui/scalar" });
|
||||
expect(observations.length).toBe(1);
|
||||
expect(observations[0]?.attributes).toEqual({ ui: "scalar" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { MeterProvider } from "@opentelemetry/api";
|
||||
import {
|
||||
type BundledAssetProbe,
|
||||
resolveAsset,
|
||||
resolveAssetUrl,
|
||||
} from "../assets/assets.js";
|
||||
import { buildCspHeader, originOf } from "../headers/csp.js";
|
||||
import { type RenderShellInput, renderShell } from "../html/render-shell.js";
|
||||
import {
|
||||
createScalarInstruments,
|
||||
recordUiRequest,
|
||||
type ScalarInstruments,
|
||||
} from "../instruments/instruments.js";
|
||||
import {
|
||||
parseScalarUiOptions,
|
||||
type ScalarUiOptions,
|
||||
type ScalarUiOptionsInput,
|
||||
} from "../options/options.js";
|
||||
|
||||
/**
|
||||
* The HTTP response value returned by the handlers. Mirrors the shape
|
||||
* consumed by `@suaveplan/api-router` adapters (in-memory, Hono, worker
|
||||
* bridge) — concrete adapters wrap this with their native request/response
|
||||
* context.
|
||||
*/
|
||||
export interface ScalarHttpResponse {
|
||||
readonly status: number;
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
/** Minimal request shape consumed by the handler. */
|
||||
export interface ScalarHttpRequest {
|
||||
readonly method: string;
|
||||
readonly path: string;
|
||||
/** Optional per-request CSP nonce — embedded verbatim in the shell. */
|
||||
readonly nonce?: string;
|
||||
}
|
||||
|
||||
/** Callable signature returned by {@link createScalarUiHandler}. */
|
||||
export type ScalarUiHandler = (
|
||||
request: ScalarHttpRequest
|
||||
) => ScalarHttpResponse;
|
||||
|
||||
/**
|
||||
* Input to {@link createScalarUiHandler}. Inherits every field of
|
||||
* {@link ScalarUiOptionsInput} and adds optional dependency-injection
|
||||
* hooks for the OTel meter provider and bundled-asset probe.
|
||||
*/
|
||||
export interface CreateScalarUiHandlerInput extends ScalarUiOptionsInput {
|
||||
readonly meterProvider?: MeterProvider;
|
||||
readonly bundledAssetProbe?: BundledAssetProbe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configured Scalar UI handler bundle returned by
|
||||
* {@link createScalarUiHandler}.
|
||||
*/
|
||||
export interface ScalarUiHandlerBundle {
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly options: ScalarUiOptions;
|
||||
readonly instruments: ScalarInstruments;
|
||||
readonly runtimeUrl: string;
|
||||
readonly handler: ScalarUiHandler;
|
||||
readonly assetHandler: ScalarUiHandler;
|
||||
}
|
||||
|
||||
const PACKAGE_NAME = "@suaveplan/api-openapi-ui-scalar";
|
||||
const PACKAGE_VERSION = "0.1.0";
|
||||
|
||||
interface BuildHandlerContext {
|
||||
readonly options: ScalarUiOptions;
|
||||
readonly instruments: ScalarInstruments;
|
||||
readonly runtimeUrl: string;
|
||||
readonly cspBaseHeaders: Readonly<Record<string, string>>;
|
||||
readonly resolvedOpenapiUrl: string;
|
||||
}
|
||||
|
||||
function buildHandler(ctx: BuildHandlerContext): ScalarUiHandler {
|
||||
return (request: ScalarHttpRequest): ScalarHttpResponse => {
|
||||
const shellInput: RenderShellInput = {
|
||||
options: ctx.options,
|
||||
openapiUrl: ctx.resolvedOpenapiUrl,
|
||||
runtimeUrl: ctx.runtimeUrl,
|
||||
...(request.nonce === undefined ? {} : { nonce: request.nonce }),
|
||||
};
|
||||
const html = renderShell(shellInput);
|
||||
recordUiRequest(ctx.instruments);
|
||||
return {
|
||||
status: 200,
|
||||
headers: {
|
||||
...ctx.cspBaseHeaders,
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
},
|
||||
body: html,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function lastPathSegment(path: string): string {
|
||||
const slash = path.lastIndexOf("/");
|
||||
return slash === -1 ? path : path.slice(slash + 1);
|
||||
}
|
||||
|
||||
function buildAssetHandler(options: ScalarUiOptions): ScalarUiHandler {
|
||||
const prefix = `${options.uiEndpoint}/assets/`;
|
||||
return (request) => {
|
||||
const path = request.path;
|
||||
const filename = path.startsWith(prefix)
|
||||
? path.slice(prefix.length)
|
||||
: lastPathSegment(path);
|
||||
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,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function buildCspBaseHeaders(
|
||||
options: ScalarUiOptions,
|
||||
resolvedOpenapiUrl: string
|
||||
): Readonly<Record<string, string>> {
|
||||
const cdnOrigin =
|
||||
options.assetMode === "cdn" ? originOf(options.cdnUrl) : undefined;
|
||||
const openapiOrigin = originOf(resolvedOpenapiUrl);
|
||||
const csp = buildCspHeader({
|
||||
assetMode: options.assetMode,
|
||||
...(cdnOrigin === undefined ? {} : { cdnOrigin }),
|
||||
...(openapiOrigin === undefined ? {} : { openapiOrigin }),
|
||||
});
|
||||
return { [csp.name]: csp.value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a Scalar UI HTTP handler.
|
||||
*
|
||||
* Validates the caller-supplied options through the Zod schema and
|
||||
* resolves the Scalar runtime asset URL eagerly so a misconfigured host
|
||||
* fails at construction time rather than at first request. The returned
|
||||
* bundle is frozen and idempotent — repeated invocations of `handler`
|
||||
* produce identical bodies for identical request inputs.
|
||||
*/
|
||||
export function createScalarUiHandler(
|
||||
input: CreateScalarUiHandlerInput = {}
|
||||
): ScalarUiHandlerBundle {
|
||||
const { meterProvider, bundledAssetProbe, ...rawOptions } = input;
|
||||
const options = parseScalarUiOptions(rawOptions as ScalarUiOptionsInput);
|
||||
const runtime = resolveAssetUrl({
|
||||
mode: options.assetMode,
|
||||
cdnUrl: options.cdnUrl,
|
||||
uiEndpoint: options.uiEndpoint,
|
||||
...(bundledAssetProbe === undefined
|
||||
? {}
|
||||
: { probe: bundledAssetProbe }),
|
||||
});
|
||||
const instruments = createScalarInstruments(
|
||||
meterProvider === undefined ? {} : { meterProvider }
|
||||
);
|
||||
const cspBaseHeaders = buildCspBaseHeaders(options, options.openapiUrl);
|
||||
const handler = buildHandler({
|
||||
options,
|
||||
instruments,
|
||||
runtimeUrl: runtime.url,
|
||||
cspBaseHeaders,
|
||||
resolvedOpenapiUrl: options.openapiUrl,
|
||||
});
|
||||
const assetHandler = buildAssetHandler(options);
|
||||
return Object.freeze({
|
||||
name: PACKAGE_NAME,
|
||||
version: PACKAGE_VERSION,
|
||||
options,
|
||||
instruments,
|
||||
runtimeUrl: runtime.url,
|
||||
handler,
|
||||
assetHandler,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
type CreateScalarUiHandlerInput,
|
||||
createScalarUiHandler,
|
||||
type ScalarHttpRequest,
|
||||
type ScalarHttpResponse,
|
||||
type ScalarUiHandler,
|
||||
type ScalarUiHandlerBundle,
|
||||
} from "./handler.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
# Strict Content-Security-Policy for the Scalar UI shell
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The headers module builds the `Content-Security-Policy` header value
|
||||
emitted on every HTML response from the Scalar UI handler. The default
|
||||
posture is conservative: `default-src 'self'` plus the minimum set of
|
||||
directive relaxations the Scalar shell needs to render. In
|
||||
`assetMode = "cdn"` the policy is widened (per-handler) to whitelist the
|
||||
configured Scalar CDN origin. In `assetMode = "bundled"` the policy
|
||||
stays strict — every script/style/font load resolves to the host's own
|
||||
origin under `<uiEndpoint>/assets/`.
|
||||
|
||||
## Features
|
||||
|
||||
- `buildCspHeader({ assetMode, cdnOrigin?, openapiOrigin? })` — emits
|
||||
the `Content-Security-Policy` header pair.
|
||||
- `originOf(url)` — extracts the origin portion of a URL or returns
|
||||
`undefined` for non-URL inputs (used at handler construction to derive
|
||||
the CDN + OpenAPI origins from the parsed options).
|
||||
- `cspAllowsInlineScripts(headerValue)` — conformance predicate the
|
||||
handler test uses to prove the rendered policy never permits inline
|
||||
scripts. The Scalar bootstrap is loaded as an external `<script src>`
|
||||
so `'unsafe-inline'` in `script-src` would be a regression.
|
||||
- `'unsafe-inline'` allowed ONLY in `style-src` for the upstream Scalar
|
||||
React app's runtime style injection.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildCspHeader } from "@suaveplan/api-openapi-ui-scalar/headers";
|
||||
|
||||
const csp = buildCspHeader({ assetMode: "bundled" });
|
||||
console.log(csp.name); // "Content-Security-Policy"
|
||||
console.log(csp.value); // strict "default-src 'self'; script-src 'self'; …"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildCspHeader,
|
||||
cspAllowsInlineScripts,
|
||||
originOf,
|
||||
} from "@suaveplan/api-openapi-ui-scalar/headers";
|
||||
|
||||
const cdnOrigin = originOf("https://cdn.jsdelivr.net/npm/@scalar/api-reference");
|
||||
const openapiOrigin = originOf("https://api.example.com/openapi.json");
|
||||
const { name, value } = buildCspHeader({
|
||||
assetMode: "cdn",
|
||||
cdnOrigin,
|
||||
openapiOrigin,
|
||||
});
|
||||
|
||||
if (cspAllowsInlineScripts(value)) {
|
||||
throw new Error("Scalar UI must never emit unsafe-inline scripts");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CSP_HEADER_NAME` — the canonical header name string.
|
||||
- `BuildCspInput` — `{ assetMode, cdnOrigin?, openapiOrigin? }`.
|
||||
- `buildCspHeader(input)` — `{ name, value }`.
|
||||
- `originOf(url)` — `string | undefined`.
|
||||
- `cspAllowsInlineScripts(headerValue)` — `boolean`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The policy structure is built directive-by-directive rather than via a
|
||||
single template string so future per-environment relaxations
|
||||
(e.g. nonce-based `script-src`) can be added without changing every
|
||||
call site. `script-src` never includes `'unsafe-inline'`; this is a
|
||||
load-bearing invariant enforced by the conformance predicate exposed
|
||||
above. The CDN origin is computed once at handler construction so a
|
||||
malicious `cdnUrl` value cannot leak into the response header — Zod
|
||||
rejects non-URL inputs at the options layer before `originOf` runs.
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
buildCspHeader,
|
||||
CSP_HEADER_NAME,
|
||||
cspAllowsInlineScripts,
|
||||
originOf,
|
||||
} from "./csp.js";
|
||||
|
||||
describe("buildCspHeader", () => {
|
||||
it("emits a strict bundled-mode policy", { timeout: 5000 }, () => {
|
||||
const csp = buildCspHeader({ assetMode: "bundled" });
|
||||
expect(csp.name).toBe(CSP_HEADER_NAME);
|
||||
expect(csp.value).toContain("default-src 'self'");
|
||||
expect(csp.value).toContain("script-src 'self'");
|
||||
expect(cspAllowsInlineScripts(csp.value)).toBe(false);
|
||||
});
|
||||
|
||||
it("widens script-src + style-src to the cdn origin in cdn mode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const csp = buildCspHeader({
|
||||
assetMode: "cdn",
|
||||
cdnOrigin: "https://cdn.example.com",
|
||||
});
|
||||
expect(csp.value).toContain("https://cdn.example.com");
|
||||
expect(csp.value).toContain(
|
||||
"script-src 'self' https://cdn.example.com"
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the openapi origin to connect-src when cross-origin", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const csp = buildCspHeader({
|
||||
assetMode: "bundled",
|
||||
openapiOrigin: "https://api.example.com",
|
||||
});
|
||||
expect(csp.value).toContain(
|
||||
"connect-src 'self' https://api.example.com"
|
||||
);
|
||||
});
|
||||
|
||||
it("omits cdn directive when cdnOrigin is undefined even in cdn mode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const csp = buildCspHeader({ assetMode: "cdn" });
|
||||
expect(csp.value).toContain("script-src 'self'");
|
||||
expect(csp.value).not.toContain("https://");
|
||||
});
|
||||
});
|
||||
|
||||
describe("originOf", () => {
|
||||
it("returns the origin for absolute URLs", { timeout: 5000 }, () => {
|
||||
expect(originOf("https://api.example.com/x")).toBe(
|
||||
"https://api.example.com"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for non-URL inputs", { timeout: 5000 }, () => {
|
||||
expect(originOf("/openapi.json")).toBeUndefined();
|
||||
expect(originOf("not-a-url")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cspAllowsInlineScripts", () => {
|
||||
it("returns true when script-src includes unsafe-inline", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
cspAllowsInlineScripts(
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when script-src is absent", { timeout: 5000 }, () => {
|
||||
expect(cspAllowsInlineScripts("default-src 'self'")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Content-Security-Policy header values emitted by the Scalar UI handler.
|
||||
*
|
||||
* In `assetMode = "bundled"` the shell only loads scripts/styles from its
|
||||
* own origin, so a strict `'self'`-only policy is sufficient. In
|
||||
* `assetMode = "cdn"` the policy is widened to whitelist the configured
|
||||
* Scalar CDN origin (parsed at handler construction time, never derived
|
||||
* from request input).
|
||||
*
|
||||
* Inline scripts are NEVER permitted — the JSON config block uses the
|
||||
* inert `type="application/json"` content type so it is not script-src
|
||||
* material. Inline styles ARE permitted under `'unsafe-inline'` for the
|
||||
* upstream Scalar React app's runtime style injection.
|
||||
*/
|
||||
export const CSP_HEADER_NAME = "Content-Security-Policy";
|
||||
|
||||
export interface BuildCspInput {
|
||||
readonly assetMode: "cdn" | "bundled";
|
||||
readonly cdnOrigin?: string;
|
||||
readonly openapiOrigin?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the CSP header pair emitted on the HTML response. `cdnOrigin`
|
||||
* is included only in cdn mode; `openapiOrigin` is included in
|
||||
* `connect-src` only when the OpenAPI document is served from a
|
||||
* different origin (cross-origin host setups).
|
||||
*/
|
||||
export function buildCspHeader(input: BuildCspInput): {
|
||||
readonly name: string;
|
||||
readonly value: string;
|
||||
} {
|
||||
const scriptSrcs = ["'self'"];
|
||||
const styleSrcs = ["'self'", "'unsafe-inline'"];
|
||||
const connectSrcs = ["'self'"];
|
||||
const imgSrcs = ["'self'", "data:"];
|
||||
const fontSrcs = ["'self'", "data:"];
|
||||
if (input.assetMode === "cdn" && input.cdnOrigin !== undefined) {
|
||||
scriptSrcs.push(input.cdnOrigin);
|
||||
styleSrcs.push(input.cdnOrigin);
|
||||
fontSrcs.push(input.cdnOrigin);
|
||||
}
|
||||
if (input.openapiOrigin !== undefined) {
|
||||
connectSrcs.push(input.openapiOrigin);
|
||||
}
|
||||
const directives = [
|
||||
"default-src 'self'",
|
||||
`script-src ${scriptSrcs.join(" ")}`,
|
||||
`style-src ${styleSrcs.join(" ")}`,
|
||||
`connect-src ${connectSrcs.join(" ")}`,
|
||||
`img-src ${imgSrcs.join(" ")}`,
|
||||
`font-src ${fontSrcs.join(" ")}`,
|
||||
];
|
||||
return { name: CSP_HEADER_NAME, value: directives.join("; ") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the origin portion of a URL, or `undefined` when the input is
|
||||
* not a parseable absolute URL. Used to narrow CSP `script-src` and
|
||||
* `connect-src` to the exact upstream CDN / API origin rather than the
|
||||
* scheme-only fallback.
|
||||
*/
|
||||
export function originOf(url: string): string | undefined {
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Conformance predicate — the rendered policy MUST NOT allow inline
|
||||
* scripts. The Scalar bootstrap is loaded as an external `<script src>`
|
||||
* so `'unsafe-inline'` in `script-src` would be a regression.
|
||||
*/
|
||||
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,7 @@
|
||||
export {
|
||||
type BuildCspInput,
|
||||
buildCspHeader,
|
||||
CSP_HEADER_NAME,
|
||||
cspAllowsInlineScripts,
|
||||
originOf,
|
||||
} from "./csp.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
escapeHtml,
|
||||
type RenderShellInput,
|
||||
renderShell,
|
||||
} from "./render-shell.js";
|
||||
@@ -0,0 +1,83 @@
|
||||
# Scalar HTML shell renderer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The render-shell module emits the HTML body returned by the Scalar UI
|
||||
handler. It embeds the configured OpenAPI document URL into a
|
||||
`<script id="scalar-config" type="application/json">` block (inert under
|
||||
strict CSP — JSON is not a script-src vector), references the resolved
|
||||
Scalar runtime URL (CDN or bundled), and renders a small status / path
|
||||
listing placeholder so even the air-gapped bundled bootstrap has
|
||||
something to populate. When the operator runs nonce-based CSP, the
|
||||
`renderShell` API accepts a `nonce` value the renderer threads onto the
|
||||
inline `<script>` tags.
|
||||
|
||||
## Features
|
||||
|
||||
- `escapeHtml(input)` — replaces `& < > " '` with their entity
|
||||
equivalents. Used on every caller-supplied string embedded in the
|
||||
shell.
|
||||
- `renderShell({ options, openapiUrl, runtimeUrl, nonce? })` — emits the
|
||||
full HTML body. The body references `openapiUrl` exactly once in the
|
||||
Scalar configuration block per the requirement spec.
|
||||
- Bundled-mode shells include a `<link rel="stylesheet">` to the local
|
||||
CSS asset; cdn-mode shells omit the link because the upstream Scalar
|
||||
bundle ships its own styles.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { renderShell } from "@suaveplan/api-openapi-ui-scalar/html";
|
||||
import { parseScalarUiOptions } from "@suaveplan/api-openapi-ui-scalar/options";
|
||||
|
||||
const options = parseScalarUiOptions({ openapiUrl: "/openapi.json" });
|
||||
const html = renderShell({
|
||||
options,
|
||||
openapiUrl: "/openapi.json",
|
||||
runtimeUrl: "https://cdn.jsdelivr.net/npm/@scalar/api-reference",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { renderShell } from "@suaveplan/api-openapi-ui-scalar/html";
|
||||
import { parseScalarUiOptions } from "@suaveplan/api-openapi-ui-scalar/options";
|
||||
|
||||
const options = parseScalarUiOptions({
|
||||
openapiUrl: "/openapi.json?plugin=billing",
|
||||
theme: "deepSpace",
|
||||
assetMode: "bundled",
|
||||
});
|
||||
|
||||
// Per-request nonce for hosts running nonce-based CSP.
|
||||
const html = renderShell({
|
||||
options,
|
||||
openapiUrl: options.openapiUrl,
|
||||
runtimeUrl: "/docs/ui/scalar/assets/scalar.<hash>.js",
|
||||
nonce: "n-9b2f7c2a",
|
||||
});
|
||||
|
||||
// The body references the configured OpenAPI URL exactly once.
|
||||
console.assert(html.split(options.openapiUrl).length - 1 === 1);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RenderShellInput` — `{ options, openapiUrl, runtimeUrl, nonce? }`.
|
||||
- `renderShell(input)` — returns the full HTML body string.
|
||||
- `escapeHtml(input)` — entity-escapes the five hazardous characters.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The configuration block JSON is post-processed to replace every `<` with
|
||||
its unicode escape so string values like `"foo</script>"` cannot
|
||||
terminate the script tag. This is the same defence applied by the
|
||||
sibling `api-graphql-ui-graphiql` package; the technique is documented
|
||||
by OWASP and is the strictest path that keeps the policy free of
|
||||
`'unsafe-inline'`. The OpenAPI URL is embedded inside the JSON block
|
||||
exactly once — verified by the handler conformance test.
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parseScalarUiOptions } from "../options/options.js";
|
||||
import { escapeHtml, renderShell } from "./render-shell.js";
|
||||
|
||||
describe("escapeHtml", () => {
|
||||
it("entity-escapes the five hazardous characters", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(escapeHtml("<a href=\"'&'\">")).toBe(
|
||||
"<a href="'&'">"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderShell", () => {
|
||||
it("embeds openapiUrl exactly once inside the config block", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const options = parseScalarUiOptions({});
|
||||
const html = renderShell({
|
||||
options,
|
||||
openapiUrl: "/openapi.json",
|
||||
runtimeUrl: "/docs/ui/scalar/assets/scalar.abcdef12.js",
|
||||
});
|
||||
const count = html.split("/openapi.json").length - 1;
|
||||
expect(count).toBe(1);
|
||||
expect(html).toContain('<script id="scalar-config"');
|
||||
});
|
||||
|
||||
it("JSON-escapes any < character in embedded strings", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const options = parseScalarUiOptions({
|
||||
openapiUrl: "/openapi.json",
|
||||
title: "Suave <Plan>",
|
||||
});
|
||||
const html = renderShell({
|
||||
options,
|
||||
openapiUrl: "/openapi.json",
|
||||
runtimeUrl: "https://cdn.example.com/scalar",
|
||||
});
|
||||
expect(html).toContain("\\u003cPlan>");
|
||||
});
|
||||
|
||||
it("includes the bundled CSS link only in bundled mode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bundled = renderShell({
|
||||
options: parseScalarUiOptions({ assetMode: "bundled" }),
|
||||
openapiUrl: "/openapi.json",
|
||||
runtimeUrl: "/docs/ui/scalar/assets/scalar.x.js",
|
||||
});
|
||||
expect(bundled).toContain("scalar");
|
||||
expect(bundled).toContain('<link rel="stylesheet"');
|
||||
|
||||
const cdn = renderShell({
|
||||
options: parseScalarUiOptions({ assetMode: "cdn" }),
|
||||
openapiUrl: "/openapi.json",
|
||||
runtimeUrl: "https://cdn.example.com/scalar",
|
||||
});
|
||||
expect(cdn).not.toContain('<link rel="stylesheet"');
|
||||
});
|
||||
|
||||
it("threads nonce onto inline script tags when supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const html = renderShell({
|
||||
options: parseScalarUiOptions({}),
|
||||
openapiUrl: "/openapi.json",
|
||||
runtimeUrl: "https://cdn.example.com/scalar",
|
||||
nonce: "abc",
|
||||
});
|
||||
expect(html.split('nonce="abc"').length - 1).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { hashedAssetPath } from "../assets/assets.js";
|
||||
import type { ScalarUiOptions } from "../options/options.js";
|
||||
|
||||
/**
|
||||
* Minimal HTML escape — replaces the five characters that have special
|
||||
* meaning in HTML attribute / text contexts. Used to safely embed
|
||||
* caller-supplied title / theme / endpoint strings into the rendered
|
||||
* shell.
|
||||
*/
|
||||
export function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Inputs threaded into the rendered shell beyond the parsed options. */
|
||||
export interface RenderShellInput {
|
||||
readonly options: ScalarUiOptions;
|
||||
/** Document URL the Scalar shell points at (may carry query string). */
|
||||
readonly openapiUrl: string;
|
||||
/** Resolved Scalar runtime URL (CDN or bundled asset path). */
|
||||
readonly runtimeUrl: string;
|
||||
/** Optional per-request CSP nonce embedded on inline JSON config. */
|
||||
readonly nonce?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Scalar HTML shell.
|
||||
*
|
||||
* The shell embeds the configured `openapiUrl` into a
|
||||
* `<script id="scalar-config" type="application/json">` block (NOT
|
||||
* executable JavaScript — keeps the strict `script-src 'self'` posture
|
||||
* intact) and references the Scalar runtime via the resolved
|
||||
* `runtimeUrl` (either the upstream CDN or `<uiEndpoint>/assets/<hash>`).
|
||||
*
|
||||
* In bundled mode the local stylesheet is also referenced; in cdn mode
|
||||
* the upstream bundle ships its own styles so the local sheet is omitted.
|
||||
*
|
||||
* `nonce`, when supplied, is added to the `<script>` tag attributes so
|
||||
* hosts running nonce-based CSP can still embed the config block.
|
||||
*/
|
||||
export function renderShell(input: RenderShellInput): string {
|
||||
const { options, openapiUrl, runtimeUrl, nonce } = input;
|
||||
const config = {
|
||||
openapiUrl,
|
||||
theme: options.theme,
|
||||
layout: options.layout,
|
||||
title: options.title,
|
||||
darkMode: options.darkMode,
|
||||
hideSearch: options.hideSearch,
|
||||
defaultOpenAllTags: options.defaultOpenAllTags,
|
||||
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 themeAttr = escapeHtml(options.theme);
|
||||
const title = escapeHtml(options.title);
|
||||
const nonceAttr =
|
||||
nonce === undefined ? "" : ` nonce="${escapeHtml(nonce)}"`;
|
||||
const bundledCssHref = `${escapeHtml(options.uiEndpoint)}/assets/${escapeHtml(hashedAssetPath("scalar.css"))}`;
|
||||
const linkBlock =
|
||||
options.assetMode === "bundled"
|
||||
? `<link rel="stylesheet" href="${bundledCssHref}" />`
|
||||
: "";
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en" data-theme="${themeAttr}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${title}</title>
|
||||
${linkBlock}
|
||||
</head>
|
||||
<body data-theme="${themeAttr}">
|
||||
<script id="scalar-config" type="application/json"${nonceAttr}>${configJson}</script>
|
||||
<div id="scalar-root" class="scalar-shell">
|
||||
<header class="scalar-header"><strong>${title}</strong></header>
|
||||
<main class="scalar-body">
|
||||
<p id="scalar-status" class="scalar-status">initialising…</p>
|
||||
<ul id="scalar-paths" class="scalar-paths"></ul>
|
||||
</main>
|
||||
</div>
|
||||
<script src="${escapeHtml(runtimeUrl)}"${nonceAttr} data-configuration-source="#scalar-config"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
71
packages/api-stack/api/api-openapi-ui-scalar/src/index.ts
Normal file
71
packages/api-stack/api/api-openapi-ui-scalar/src/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Side-effect import — surfaces the OPENAPI_UI_SCALAR error-code
|
||||
// registry to consumers via `import "@suaveplan/api-openapi-ui-scalar"`.
|
||||
import "./error-codes/error-codes.js";
|
||||
|
||||
// Assets
|
||||
export {
|
||||
type BundledAssetProbe,
|
||||
defaultBundledAssetProbe,
|
||||
fnv1a32,
|
||||
hashedAssetPath,
|
||||
listScalarAssetNames,
|
||||
type ResolveAssetUrlInput,
|
||||
type ResolvedAssetUrl,
|
||||
resolveAsset,
|
||||
resolveAssetUrl,
|
||||
SCALAR_ASSETS,
|
||||
type ScalarAsset,
|
||||
} from "./assets/assets.js";
|
||||
// Error codes
|
||||
export {
|
||||
OPENAPI_UI_SCALAR_CODES,
|
||||
type OpenapiUiScalarCode,
|
||||
OpenapiUiScalarError,
|
||||
} from "./error-codes/error-codes.js";
|
||||
// Handler
|
||||
export {
|
||||
type CreateScalarUiHandlerInput,
|
||||
createScalarUiHandler,
|
||||
type ScalarHttpRequest,
|
||||
type ScalarHttpResponse,
|
||||
type ScalarUiHandler,
|
||||
type ScalarUiHandlerBundle,
|
||||
} from "./handler/handler.js";
|
||||
// Headers
|
||||
export {
|
||||
type BuildCspInput,
|
||||
buildCspHeader,
|
||||
CSP_HEADER_NAME,
|
||||
cspAllowsInlineScripts,
|
||||
originOf,
|
||||
} from "./headers/csp.js";
|
||||
// HTML
|
||||
export {
|
||||
escapeHtml,
|
||||
type RenderShellInput,
|
||||
renderShell,
|
||||
} from "./html/render-shell.js";
|
||||
// Instruments
|
||||
export {
|
||||
createScalarInstruments,
|
||||
recordUiRequest,
|
||||
type ScalarInstruments,
|
||||
type ScalarInstrumentsConfig,
|
||||
UI_LABEL_VALUE,
|
||||
} from "./instruments/instruments.js";
|
||||
// Options
|
||||
export {
|
||||
parseScalarUiOptions,
|
||||
SCALAR_ASSET_MODES,
|
||||
SCALAR_LAYOUTS,
|
||||
SCALAR_THEMES,
|
||||
type ScalarUiOptions,
|
||||
type ScalarUiOptionsInput,
|
||||
scalarUiOptionsSchema,
|
||||
} from "./options/options.js";
|
||||
// Per-plugin
|
||||
export {
|
||||
type CreatePerPluginScalarUiHandlerInput,
|
||||
createPerPluginScalarUiHandler,
|
||||
type PerPluginScalarUiHandlerBundle,
|
||||
} from "./per-plugin/per-plugin.js";
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createScalarInstruments,
|
||||
type RecordUiRequestAttributes,
|
||||
recordUiRequest,
|
||||
type ScalarInstruments,
|
||||
type ScalarInstrumentsConfig,
|
||||
UI_LABEL_VALUE,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,69 @@
|
||||
# Scalar UI OTel instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The instruments module wires the Scalar UI handler's OTel instruments
|
||||
into a frozen bundle constructed once at handler-mount time. The bundle
|
||||
defaults to the OTel global meter provider, which itself falls back to
|
||||
a no-op implementation when no SDK is installed, so the package works
|
||||
in test environments without ceremony. The bundle is intentionally
|
||||
small — UI shells stay passive and the bulk of observability is
|
||||
delegated to the surrounding router request metrics.
|
||||
|
||||
## Features
|
||||
|
||||
- `createScalarInstruments(config?)` — builds the meter + counter.
|
||||
- `recordUiRequest(instruments, { plugin? })` — adds a single
|
||||
observation. Always carries `{ ui: "scalar" }`; the per-plugin handler
|
||||
fans out a second observation labelled with `{ plugin }`.
|
||||
- `UI_LABEL_VALUE` — exported so siblings (`api-openapi-ui-swagger`,
|
||||
`api-openapi-ui-redoc`) share the naming convention.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createScalarInstruments,
|
||||
recordUiRequest,
|
||||
} from "@suaveplan/api-openapi-ui-scalar/instruments";
|
||||
|
||||
const instruments = createScalarInstruments();
|
||||
recordUiRequest(instruments);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics, type MeterProvider } from "@opentelemetry/api";
|
||||
import { createScalarInstruments } from "@suaveplan/api-openapi-ui-scalar/instruments";
|
||||
|
||||
const meterProvider: MeterProvider = metrics.getMeterProvider();
|
||||
const instruments = createScalarInstruments({ meterProvider });
|
||||
|
||||
// Per-plugin observation — labelled with both `ui` and `plugin` for
|
||||
// dashboard fan-out across multiple Scalar mounts.
|
||||
instruments.requestsTotal.add(1, { ui: "scalar", plugin: "billing" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ScalarInstrumentsConfig` — `{ meterProvider? }`.
|
||||
- `ScalarInstruments` — `{ meter, requestsTotal }` (frozen).
|
||||
- `createScalarInstruments(config?)` — returns frozen `ScalarInstruments`.
|
||||
- `recordUiRequest(instruments, attrs?)` — labels each observation with
|
||||
`{ ui: "scalar" }` plus the optional `plugin` attribute.
|
||||
- `UI_LABEL_VALUE` — the string literal `"scalar"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The counter name `openapi.ui.requests_total` is shared across every
|
||||
sibling Scalar / Swagger / Redoc UI handler so dashboards can sum across
|
||||
operator choices via the `{ ui }` attribute. Per-plugin shells emit a
|
||||
second observation rather than overriding the bare-UI one so the
|
||||
top-line total stays correct. The handler does NOT increment on asset
|
||||
sub-requests — router request metrics cover those, and double-counting
|
||||
the assets would inflate the per-request total.
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Counter, Meter, MeterProvider } from "@opentelemetry/api";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createScalarInstruments,
|
||||
recordUiRequest,
|
||||
UI_LABEL_VALUE,
|
||||
} from "./instruments.js";
|
||||
|
||||
interface RecordedObservation {
|
||||
readonly value: number;
|
||||
readonly attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function buildRecordingProvider(): {
|
||||
readonly provider: MeterProvider;
|
||||
readonly observations: RecordedObservation[];
|
||||
} {
|
||||
const observations: RecordedObservation[] = [];
|
||||
const counter = {
|
||||
add(value: number, attributes?: Record<string, unknown>): void {
|
||||
observations.push({
|
||||
value,
|
||||
attributes: { ...(attributes ?? {}) },
|
||||
});
|
||||
},
|
||||
} as unknown as Counter;
|
||||
const meter = {
|
||||
createCounter(): Counter {
|
||||
return counter;
|
||||
},
|
||||
} as unknown as Meter;
|
||||
const provider = {
|
||||
getMeter(): Meter {
|
||||
return meter;
|
||||
},
|
||||
} as unknown as MeterProvider;
|
||||
return { provider, observations };
|
||||
}
|
||||
|
||||
describe("instruments", () => {
|
||||
it("createScalarInstruments wires a counter on the supplied provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { provider, observations } = buildRecordingProvider();
|
||||
const instruments = createScalarInstruments({
|
||||
meterProvider: provider,
|
||||
});
|
||||
recordUiRequest(instruments);
|
||||
expect(observations.length).toBe(1);
|
||||
expect(observations[0]?.attributes).toEqual({ ui: UI_LABEL_VALUE });
|
||||
});
|
||||
|
||||
it("recordUiRequest labels per-plugin observations", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { provider, observations } = buildRecordingProvider();
|
||||
const instruments = createScalarInstruments({
|
||||
meterProvider: provider,
|
||||
});
|
||||
recordUiRequest(instruments, { plugin: "billing" });
|
||||
expect(observations[0]?.attributes).toEqual({
|
||||
ui: UI_LABEL_VALUE,
|
||||
plugin: "billing",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the OTel global meter provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const instruments = createScalarInstruments();
|
||||
expect(instruments.requestsTotal).toBeDefined();
|
||||
expect(instruments.meter).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Counter, Meter, MeterProvider } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Config consumed by {@link createScalarInstruments}. The provider
|
||||
* defaults to the OTel global, which falls back to a no-op
|
||||
* implementation when no SDK is installed — so the handler works in
|
||||
* test envs without ceremony.
|
||||
*/
|
||||
export interface ScalarInstrumentsConfig {
|
||||
readonly meterProvider?: MeterProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen bundle of every OTel instrument the Scalar UI handler emits.
|
||||
*
|
||||
* - `openapi.ui.requests_total` — counter labelled `{ ui: "scalar" }`
|
||||
* incremented on each successful HTML response. Per-plugin shells
|
||||
* additionally carry `{ plugin: "<name>" }`. Asset sub-requests do NOT
|
||||
* increment this counter — router request metrics cover those.
|
||||
*/
|
||||
export interface ScalarInstruments {
|
||||
readonly meter: Meter;
|
||||
readonly requestsTotal: Counter;
|
||||
}
|
||||
|
||||
const METER_NAME = "@suaveplan/api-openapi-ui-scalar";
|
||||
|
||||
/** UI label attribute attached to every observation. */
|
||||
export const UI_LABEL_VALUE = "scalar";
|
||||
|
||||
/**
|
||||
* Build the Scalar UI handler's instrument registry. Returns a frozen
|
||||
* bundle whose counter is constructed once at handler-mount time.
|
||||
*/
|
||||
export function createScalarInstruments(
|
||||
config: ScalarInstrumentsConfig = {}
|
||||
): ScalarInstruments {
|
||||
const meterProvider = config.meterProvider ?? metrics.getMeterProvider();
|
||||
const meter = meterProvider.getMeter(METER_NAME);
|
||||
const requestsTotal = meter.createCounter("openapi.ui.requests_total");
|
||||
return Object.freeze({ meter, requestsTotal });
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a UI HTML request. Always labels the observation with
|
||||
* `{ ui: "scalar" }`; the optional `plugin` attribute fans out per-plugin
|
||||
* mounts.
|
||||
*/
|
||||
export interface RecordUiRequestAttributes {
|
||||
readonly plugin?: string;
|
||||
}
|
||||
|
||||
export function recordUiRequest(
|
||||
instruments: ScalarInstruments,
|
||||
attributes: RecordUiRequestAttributes = {}
|
||||
): void {
|
||||
const attrs: Record<string, string> = { ui: UI_LABEL_VALUE };
|
||||
const { plugin } = attributes;
|
||||
if (plugin !== undefined) attrs["plugin"] = plugin;
|
||||
instruments.requestsTotal.add(1, attrs);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
parseScalarUiOptions,
|
||||
SCALAR_ASSET_MODES,
|
||||
SCALAR_LAYOUTS,
|
||||
SCALAR_THEMES,
|
||||
type ScalarUiOptions,
|
||||
type ScalarUiOptionsInput,
|
||||
scalarUiOptionsSchema,
|
||||
} from "./options.js";
|
||||
@@ -0,0 +1,79 @@
|
||||
# `ScalarUiOptions` — Zod-validated configuration
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The options module owns the single Zod schema consumed by every Scalar
|
||||
UI handler factory. It is exposed both directly (`scalarUiOptionsSchema`)
|
||||
and via the convenience `parseScalarUiOptions(input)` helper that wraps
|
||||
the underlying `ZodError` in an `OpenapiUiScalarError` tagged with
|
||||
`OPENAPI_UI_SCALAR.INVALID_OPTIONS`. This double-surface lets host
|
||||
config ingestion decide whether to surface raw Zod issues or rely on the
|
||||
package's stable error-code contract.
|
||||
|
||||
## Features
|
||||
|
||||
- Required `openapiUrl` field (with default `/openapi.json`).
|
||||
- Endpoint defaults match design doc §7 (`/docs/ui/scalar`).
|
||||
- Eleven built-in `theme` literals matching the upstream Scalar palette.
|
||||
- `layout` literals `"modern" | "classic"`.
|
||||
- `assetMode` literals `"cdn" | "bundled"` toggling the runtime asset
|
||||
resolution path.
|
||||
- `cdnUrl` defaults to the upstream JSDelivr URL of
|
||||
`@scalar/api-reference`.
|
||||
- `editorOptions` is an open passthrough bag for upstream Scalar config
|
||||
options the schema does not enumerate.
|
||||
- `.strict()` rejects unknown keys so typos surface at host-config load.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseScalarUiOptions } from "@suaveplan/api-openapi-ui-scalar/options";
|
||||
|
||||
const opts = parseScalarUiOptions({ openapiUrl: "/openapi.json" });
|
||||
console.log(opts.uiEndpoint); // "/docs/ui/scalar"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseScalarUiOptions,
|
||||
scalarUiOptionsSchema,
|
||||
} from "@suaveplan/api-openapi-ui-scalar/options";
|
||||
|
||||
// Schema can be composed with host-config layers — every default is
|
||||
// safe in the empty-object case so the schema can be merged with other
|
||||
// host config without surprises.
|
||||
const hostSchema = scalarUiOptionsSchema.extend({
|
||||
enabled: scalarUiOptionsSchema.shape.darkMode,
|
||||
});
|
||||
|
||||
const parsed = parseScalarUiOptions({
|
||||
openapiUrl: "/v1/openapi.json",
|
||||
theme: "deepSpace",
|
||||
layout: "classic",
|
||||
assetMode: "bundled",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `scalarUiOptionsSchema` — Zod object schema, `.strict()`.
|
||||
- `parseScalarUiOptions(input?)` — returns the resolved `ScalarUiOptions`
|
||||
or throws `OpenapiUiScalarError(INVALID_OPTIONS)`.
|
||||
- `SCALAR_THEMES` / `SCALAR_LAYOUTS` / `SCALAR_ASSET_MODES` — literal
|
||||
unions backing the schema enums.
|
||||
- `ScalarUiOptions` / `ScalarUiOptionsInput` — output / input types.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema uses Zod `.default()` on every optional field so the empty
|
||||
`{}` case round-trips to a complete options object. The `.strict()` mode
|
||||
is essential — host operators frequently mistype keys (e.g. `themes` vs
|
||||
`theme`) and the strict guard surfaces those at boot rather than at
|
||||
first request. `INVALID_OPTIONS` wraps `safeParse` failures so the error
|
||||
chain preserves the underlying issue list for log/telemetry consumers.
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
OPENAPI_UI_SCALAR_CODES,
|
||||
OpenapiUiScalarError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
parseScalarUiOptions,
|
||||
SCALAR_ASSET_MODES,
|
||||
SCALAR_LAYOUTS,
|
||||
SCALAR_THEMES,
|
||||
scalarUiOptionsSchema,
|
||||
} from "./options.js";
|
||||
|
||||
describe("scalarUiOptionsSchema", () => {
|
||||
it("applies every default when given an empty object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const opts = parseScalarUiOptions({});
|
||||
expect(opts.openapiUrl).toBe("/openapi.json");
|
||||
expect(opts.uiEndpoint).toBe("/docs/ui/scalar");
|
||||
expect(opts.title).toBe("API Reference");
|
||||
expect(opts.theme).toBe("default");
|
||||
expect(opts.layout).toBe("modern");
|
||||
expect(opts.assetMode).toBe("cdn");
|
||||
expect(opts.defaultOpenAllTags).toBe(false);
|
||||
expect(opts.hideSearch).toBe(false);
|
||||
expect(opts.darkMode).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a fully-specified options bundle", { timeout: 5000 }, () => {
|
||||
const opts = parseScalarUiOptions({
|
||||
openapiUrl: "/v1/openapi.json",
|
||||
uiEndpoint: "/docs/scalar",
|
||||
title: "Suaveplan API",
|
||||
theme: "deepSpace",
|
||||
layout: "classic",
|
||||
defaultOpenAllTags: true,
|
||||
hideSearch: true,
|
||||
darkMode: true,
|
||||
assetMode: "bundled",
|
||||
cdnUrl: "https://cdn.example.com/scalar",
|
||||
editorOptions: { customField: 123 },
|
||||
});
|
||||
expect(opts.theme).toBe("deepSpace");
|
||||
expect(opts.layout).toBe("classic");
|
||||
expect(opts.assetMode).toBe("bundled");
|
||||
});
|
||||
|
||||
it("invalid theme is rejected by schema", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseScalarUiOptions({
|
||||
openapiUrl: "/openapi.json",
|
||||
theme: "neon-sunset" as unknown as "default",
|
||||
});
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OpenapiUiScalarError);
|
||||
expect((err as OpenapiUiScalarError).code).toBe(
|
||||
OPENAPI_UI_SCALAR_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("uiEndpoint without leading slash is rejected", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
parseScalarUiOptions({ uiEndpoint: "docs" });
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
} catch (err) {
|
||||
expect((err as OpenapiUiScalarError).code).toBe(
|
||||
OPENAPI_UI_SCALAR_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("empty openapiUrl is rejected", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseScalarUiOptions({ openapiUrl: "" });
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
} catch (err) {
|
||||
expect((err as OpenapiUiScalarError).code).toBe(
|
||||
OPENAPI_UI_SCALAR_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("non-URL cdnUrl is rejected", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseScalarUiOptions({ cdnUrl: "not-a-url" });
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
} catch (err) {
|
||||
expect((err as OpenapiUiScalarError).code).toBe(
|
||||
OPENAPI_UI_SCALAR_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("strict mode rejects unknown keys", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseScalarUiOptions({
|
||||
// @ts-expect-error — surfacing the unknown-key rejection.
|
||||
typo: true,
|
||||
});
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
} catch (err) {
|
||||
expect((err as OpenapiUiScalarError).code).toBe(
|
||||
OPENAPI_UI_SCALAR_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("undefined input round-trips to the default bundle", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const opts = parseScalarUiOptions(undefined);
|
||||
expect(opts.openapiUrl).toBe("/openapi.json");
|
||||
});
|
||||
|
||||
it("literal arrays are non-empty and mutually exclusive", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(SCALAR_THEMES.length).toBeGreaterThan(1);
|
||||
expect(SCALAR_LAYOUTS).toEqual(["modern", "classic"]);
|
||||
expect(SCALAR_ASSET_MODES).toEqual(["cdn", "bundled"]);
|
||||
});
|
||||
|
||||
it("schema is exposed for downstream composition", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(typeof scalarUiOptionsSchema.parse).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
OPENAPI_UI_SCALAR_CODES,
|
||||
OpenapiUiScalarError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/** Built-in Scalar themes accepted by the bootstrap config. */
|
||||
export const SCALAR_THEMES = [
|
||||
"default",
|
||||
"alternate",
|
||||
"moon",
|
||||
"purple",
|
||||
"solarized",
|
||||
"bluePlanet",
|
||||
"deepSpace",
|
||||
"saturn",
|
||||
"kepler",
|
||||
"mars",
|
||||
"none",
|
||||
] as const;
|
||||
|
||||
/** Built-in Scalar layouts. */
|
||||
export const SCALAR_LAYOUTS = ["modern", "classic"] as const;
|
||||
|
||||
/** Asset-resolution mode for the Scalar JS runtime. */
|
||||
export const SCALAR_ASSET_MODES = ["cdn", "bundled"] as const;
|
||||
|
||||
/**
|
||||
* Zod schema for {@link ScalarUiOptions} with every default baked in.
|
||||
*
|
||||
* `openapiUrl` is REQUIRED — the Scalar shell needs a target document
|
||||
* URL at render time. `uiEndpoint` defaults to `/docs/ui/scalar` to match
|
||||
* the design doc §7 mount point. `assetMode = "cdn"` ships the upstream
|
||||
* Scalar bundle; `"bundled"` consumes the embedded asset under
|
||||
* `dist/assets/` for air-gapped hosts.
|
||||
*/
|
||||
export const scalarUiOptionsSchema = z
|
||||
.object({
|
||||
openapiUrl: z
|
||||
.string()
|
||||
.min(1, "openapiUrl must be a non-empty string")
|
||||
.default("/openapi.json"),
|
||||
uiEndpoint: z
|
||||
.string()
|
||||
.startsWith("/", "uiEndpoint must start with '/'")
|
||||
.default("/docs/ui/scalar"),
|
||||
title: z.string().default("API Reference"),
|
||||
theme: z.enum(SCALAR_THEMES).default("default"),
|
||||
layout: z.enum(SCALAR_LAYOUTS).default("modern"),
|
||||
defaultOpenAllTags: z.boolean().default(false),
|
||||
hideSearch: z.boolean().default(false),
|
||||
darkMode: z.boolean().default(false),
|
||||
assetMode: z.enum(SCALAR_ASSET_MODES).default("cdn"),
|
||||
cdnUrl: z
|
||||
.string()
|
||||
.url("cdnUrl must be a valid URL")
|
||||
.default("https://cdn.jsdelivr.net/npm/@scalar/api-reference"),
|
||||
editorOptions: z.record(z.string(), z.unknown()).default({}),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Resolved (post-parse) options, every default applied. */
|
||||
export type ScalarUiOptions = z.infer<typeof scalarUiOptionsSchema>;
|
||||
|
||||
/** Caller-facing options input — every field optional, defaults applied. */
|
||||
export type ScalarUiOptionsInput = z.input<typeof scalarUiOptionsSchema>;
|
||||
|
||||
/**
|
||||
* Parse + validate caller options, wrapping the underlying `ZodError` in
|
||||
* an `OpenapiUiScalarError` carrying the
|
||||
* `OPENAPI_UI_SCALAR.INVALID_OPTIONS` code so host-config ingestion can
|
||||
* discriminate on `error.code` without unpacking the Zod issue tree.
|
||||
*/
|
||||
export function parseScalarUiOptions(
|
||||
input: ScalarUiOptionsInput | undefined
|
||||
): ScalarUiOptions {
|
||||
const result = scalarUiOptionsSchema.safeParse(input ?? {});
|
||||
if (result.success) return result.data;
|
||||
throw new OpenapiUiScalarError(
|
||||
OPENAPI_UI_SCALAR_CODES.INVALID_OPTIONS,
|
||||
`Invalid ScalarUiOptions: ${result.error.message}`,
|
||||
{ cause: result.error, context: { issues: result.error.issues } }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type CreatePerPluginScalarUiHandlerInput,
|
||||
createPerPluginScalarUiHandler,
|
||||
type PerPluginScalarUiHandlerBundle,
|
||||
} from "./per-plugin.js";
|
||||
@@ -0,0 +1,91 @@
|
||||
# Per-plugin Scalar UI handler
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The per-plugin module exposes `createPerPluginScalarUiHandler`, the
|
||||
variant the host mounts under `/docs/ui/scalar/<plugin>` so each plugin
|
||||
gets its own Scalar shell pointing at a filtered OpenAPI document
|
||||
(typically `/openapi.json?plugin=<name>`). It composes the standard
|
||||
`createScalarUiHandler` factory but rewrites the OpenAPI URL passthrough
|
||||
and fans out the OTel observation with a `{ plugin }` attribute so
|
||||
dashboards summing per-plugin mounts get distinct buckets without
|
||||
losing the top-line UI total.
|
||||
|
||||
## Features
|
||||
|
||||
- Defaults `uiEndpoint` to `/docs/ui/scalar/<plugin>` when the caller
|
||||
does not override it — keeps the host's `/docs/ui/scalar` namespace
|
||||
collision-free across N plugins.
|
||||
- Embeds the host-computed filtered URL verbatim — the host is
|
||||
responsible for query-string composition so this package never has to
|
||||
re-implement the OpenAPI filter semantics.
|
||||
- Same CSP + asset + Zod validation guarantees as
|
||||
`createScalarUiHandler`.
|
||||
- Per-plugin OTel attribute fan-out — every render emits both the
|
||||
baseline `{ ui: "scalar" }` and `{ ui: "scalar", plugin }` observations
|
||||
so dashboards can sum either way without losing the top-line total.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createPerPluginScalarUiHandler } from "@suaveplan/api-openapi-ui-scalar/per-plugin";
|
||||
|
||||
const { handler } = createPerPluginScalarUiHandler({
|
||||
plugin: "billing",
|
||||
openapiUrlForPlugin: "/openapi.json?plugin=billing",
|
||||
});
|
||||
|
||||
const response = handler({
|
||||
method: "GET",
|
||||
path: "/docs/ui/scalar/billing",
|
||||
});
|
||||
console.log(response.status); // 200
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createPerPluginScalarUiHandler } from "@suaveplan/api-openapi-ui-scalar/per-plugin";
|
||||
|
||||
const bundle = createPerPluginScalarUiHandler({
|
||||
plugin: "billing",
|
||||
openapiUrlForPlugin: "/openapi.json?plugin=billing",
|
||||
theme: "deepSpace",
|
||||
assetMode: "bundled",
|
||||
uiEndpoint: "/docs/billing", // override the default mount path
|
||||
});
|
||||
|
||||
console.log(bundle.plugin); // "billing"
|
||||
console.log(bundle.options.uiEndpoint); // "/docs/billing"
|
||||
|
||||
// The shell embeds the filtered URL exactly once.
|
||||
const html = bundle.handler({
|
||||
method: "GET",
|
||||
path: "/docs/billing",
|
||||
}).body;
|
||||
console.assert(html.includes("/openapi.json?plugin=billing"));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CreatePerPluginScalarUiHandlerInput` — `Omit<ScalarUiOptionsInput,
|
||||
"openapiUrl">` plus `{ plugin, openapiUrlForPlugin, meterProvider?,
|
||||
bundledAssetProbe? }`.
|
||||
- `createPerPluginScalarUiHandler(input)` —
|
||||
`PerPluginScalarUiHandlerBundle`.
|
||||
- `PerPluginScalarUiHandlerBundle` extends `ScalarUiHandlerBundle` with
|
||||
the resolved `plugin` field.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The per-plugin factory deliberately delegates to the base
|
||||
`createScalarUiHandler` so the per-plugin path inherits every
|
||||
validation + CSP + asset-resolution guarantee. The only divergences are
|
||||
the rewritten OpenAPI URL, the default `uiEndpoint`, and the extra
|
||||
counter observation for the per-plugin attribute. Doubling the counter
|
||||
is intentional — the bare `{ ui: "scalar" }` total stays correct while
|
||||
the per-plugin breakdown remains queryable from the same series.
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createPerPluginScalarUiHandler } from "./per-plugin.js";
|
||||
|
||||
describe("createPerPluginScalarUiHandler", () => {
|
||||
it("per-plugin handler embeds filtered URL", { timeout: 5000 }, () => {
|
||||
const bundle = createPerPluginScalarUiHandler({
|
||||
plugin: "billing",
|
||||
openapiUrlForPlugin: "/openapi.json?plugin=billing",
|
||||
});
|
||||
const response = bundle.handler({
|
||||
method: "GET",
|
||||
path: "/docs/ui/scalar/billing",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const occurrences =
|
||||
response.body.split("/openapi.json?plugin=billing").length - 1;
|
||||
expect(occurrences).toBe(1);
|
||||
expect(bundle.plugin).toBe("billing");
|
||||
expect(bundle.options.uiEndpoint).toBe("/docs/ui/scalar/billing");
|
||||
});
|
||||
|
||||
it("honours caller-supplied uiEndpoint override", { timeout: 5000 }, () => {
|
||||
const bundle = createPerPluginScalarUiHandler({
|
||||
plugin: "billing",
|
||||
openapiUrlForPlugin: "/openapi.json?plugin=billing",
|
||||
uiEndpoint: "/docs/billing",
|
||||
});
|
||||
expect(bundle.options.uiEndpoint).toBe("/docs/billing");
|
||||
});
|
||||
|
||||
it("supports nonce passthrough", { timeout: 5000 }, () => {
|
||||
const { handler } = createPerPluginScalarUiHandler({
|
||||
plugin: "billing",
|
||||
openapiUrlForPlugin: "/openapi.json?plugin=billing",
|
||||
});
|
||||
const response = handler({
|
||||
method: "GET",
|
||||
path: "/docs/ui/scalar/billing",
|
||||
nonce: "n-9b2f7c2a",
|
||||
});
|
||||
expect(response.body).toContain('nonce="n-9b2f7c2a"');
|
||||
});
|
||||
|
||||
it("exposes the underlying assetHandler", { timeout: 5000 }, () => {
|
||||
const bundle = createPerPluginScalarUiHandler({
|
||||
plugin: "billing",
|
||||
openapiUrlForPlugin: "/openapi.json?plugin=billing",
|
||||
assetMode: "bundled",
|
||||
});
|
||||
const response = bundle.assetHandler({
|
||||
method: "GET",
|
||||
path: `${bundle.options.uiEndpoint}/assets/scalar.js`,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { MeterProvider } from "@opentelemetry/api";
|
||||
import type { BundledAssetProbe } from "../assets/assets.js";
|
||||
import {
|
||||
createScalarUiHandler,
|
||||
type ScalarHttpRequest,
|
||||
type ScalarUiHandlerBundle,
|
||||
} from "../handler/handler.js";
|
||||
import { recordUiRequest } from "../instruments/instruments.js";
|
||||
import type { ScalarUiOptionsInput } from "../options/options.js";
|
||||
|
||||
/**
|
||||
* Input to {@link createPerPluginScalarUiHandler}. The host computes the
|
||||
* plugin-filtered OpenAPI document URL upstream — usually
|
||||
* `"/openapi.json?plugin=<name>"` — and passes it through verbatim so
|
||||
* the shell embeds the exact filter parameters the host wants exposed.
|
||||
*/
|
||||
export interface CreatePerPluginScalarUiHandlerInput
|
||||
extends Omit<ScalarUiOptionsInput, "openapiUrl"> {
|
||||
/** Plugin name whose schema this UI mount surfaces. */
|
||||
readonly plugin: string;
|
||||
/** Filtered URL the shell points at — e.g. `/openapi.json?plugin=billing`. */
|
||||
readonly openapiUrlForPlugin: string;
|
||||
/** Optional OTel meter provider — defaults to the global. */
|
||||
readonly meterProvider?: MeterProvider;
|
||||
/** Optional asset-bundle probe (DI for tests). */
|
||||
readonly bundledAssetProbe?: BundledAssetProbe;
|
||||
}
|
||||
|
||||
/** Bundle returned by the per-plugin factory — adds `plugin` for routing. */
|
||||
export interface PerPluginScalarUiHandlerBundle extends ScalarUiHandlerBundle {
|
||||
readonly plugin: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a per-plugin Scalar UI handler whose shell points at the
|
||||
* plugin-filtered OpenAPI URL.
|
||||
*
|
||||
* `uiEndpoint` defaults to `/docs/ui/scalar/<plugin>` when the caller
|
||||
* does not override it so multiple per-plugin shells can co-exist under
|
||||
* the host's `/docs/ui/scalar` namespace without colliding. The
|
||||
* counter observation is overridden so the per-plugin mount records
|
||||
* `{ ui: "scalar", plugin }` instead of the bare `{ ui: "scalar" }`
|
||||
* label set the global handler emits.
|
||||
*/
|
||||
export function createPerPluginScalarUiHandler(
|
||||
input: CreatePerPluginScalarUiHandlerInput
|
||||
): PerPluginScalarUiHandlerBundle {
|
||||
const { plugin, openapiUrlForPlugin, ...rest } = input;
|
||||
const defaultUiEndpoint = `/docs/ui/scalar/${plugin}`;
|
||||
const baseBundle = createScalarUiHandler({
|
||||
...rest,
|
||||
openapiUrl: openapiUrlForPlugin,
|
||||
uiEndpoint: rest.uiEndpoint ?? defaultUiEndpoint,
|
||||
});
|
||||
const pluginHandler = (request: ScalarHttpRequest) => {
|
||||
const response = baseBundle.handler(request);
|
||||
// The base handler already recorded the bare `{ ui }` observation.
|
||||
// Layer on the per-plugin attribute set so dashboards summing
|
||||
// per-plugin mounts get distinct buckets without losing the
|
||||
// top-line total.
|
||||
recordUiRequest(baseBundle.instruments, { plugin });
|
||||
return response;
|
||||
};
|
||||
return Object.freeze({ ...baseBundle, handler: pluginHandler, plugin });
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
interface PackageManifest {
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly dependencies: Record<string, string>;
|
||||
readonly devDependencies: Record<string, string>;
|
||||
}
|
||||
|
||||
const manifestPath = fileURLToPath(new URL("../package.json", import.meta.url));
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(manifestPath, "utf8")
|
||||
) as PackageManifest;
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("package name and version are stable", { timeout: 5000 }, () => {
|
||||
expect(manifest.name).toBe("@suaveplan/api-openapi-ui-scalar");
|
||||
expect(manifest.version).toBe("0.1.0");
|
||||
});
|
||||
|
||||
it("runtime deps cover error, zod, otel api, tslib", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const deps = manifest.dependencies;
|
||||
expect(deps["@suaveplan/error"]).toBe("workspace:^");
|
||||
expect(deps["@opentelemetry/api"]).toBeDefined();
|
||||
expect(deps.zod).toBeDefined();
|
||||
expect(deps.tslib).toBeDefined();
|
||||
});
|
||||
|
||||
it("does NOT pull in router (handler is router-agnostic)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(manifest.dependencies["@suaveplan/api-router"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT pull in api-openapi at runtime (consumed by the host, not us)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(manifest.dependencies["@suaveplan/api-openapi"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("testing kit is a devDependency only", { timeout: 5000 }, () => {
|
||||
expect(manifest.devDependencies["@suaveplan/testing"]).toBe(
|
||||
"workspace:^"
|
||||
);
|
||||
expect(manifest.dependencies["@suaveplan/testing"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
30
packages/api-stack/api/api-openapi-ui-scalar/vite.config.ts
Normal file
30
packages/api-stack/api/api-openapi-ui-scalar/vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../../node_modules/.vite/packages/api-openapi-ui-scalar",
|
||||
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