Initial Commit

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

View File

@@ -0,0 +1,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`).

View 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"
]
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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());
}

View File

@@ -0,0 +1,13 @@
export {
type BundledAssetProbe,
defaultBundledAssetProbe,
fnv1a32,
hashedAssetPath,
listScalarAssetNames,
type ResolveAssetUrlInput,
type ResolvedAssetUrl,
resolveAsset,
resolveAssetUrl,
SCALAR_ASSETS,
type ScalarAsset,
} from "./assets.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
export {
OPENAPI_UI_SCALAR_CODES,
type OpenapiUiScalarCode,
OpenapiUiScalarError,
} from "./error-codes.js";

View File

@@ -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.

View File

@@ -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" });
});
});

View File

@@ -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,
});
}

View File

@@ -0,0 +1,8 @@
export {
type CreateScalarUiHandlerInput,
createScalarUiHandler,
type ScalarHttpRequest,
type ScalarHttpResponse,
type ScalarUiHandler,
type ScalarUiHandlerBundle,
} from "./handler.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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'");
}

View File

@@ -0,0 +1,7 @@
export {
type BuildCspInput,
buildCspHeader,
CSP_HEADER_NAME,
cspAllowsInlineScripts,
originOf,
} from "./csp.js";

View File

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

View File

@@ -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.

View File

@@ -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(
"&lt;a href=&quot;&#39;&amp;&#39;&quot;&gt;"
);
});
});
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);
});
});

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/** 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>
`;
}

View 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";

View File

@@ -0,0 +1,8 @@
export {
createScalarInstruments,
type RecordUiRequestAttributes,
recordUiRequest,
type ScalarInstruments,
type ScalarInstrumentsConfig,
UI_LABEL_VALUE,
} from "./instruments.js";

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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);
}

View File

@@ -0,0 +1,9 @@
export {
parseScalarUiOptions,
SCALAR_ASSET_MODES,
SCALAR_LAYOUTS,
SCALAR_THEMES,
type ScalarUiOptions,
type ScalarUiOptionsInput,
scalarUiOptionsSchema,
} from "./options.js";

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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 } }
);
}

View File

@@ -0,0 +1,5 @@
export {
type CreatePerPluginScalarUiHandlerInput,
createPerPluginScalarUiHandler,
type PerPluginScalarUiHandlerBundle,
} from "./per-plugin.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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 });
}

View File

@@ -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();
});
});

View File

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

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-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"],
},
},
});

View File

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