nuxt-safe-runtime-config
Version:
Validate Nuxt runtime config with Standard Schema at build time
386 lines (371 loc) • 14.6 kB
JavaScript
import { existsSync } from 'node:fs';
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import { join, relative, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { consola } from 'consola';
import { createJiti } from 'jiti';
import process from 'node:process';
import { toJsonSchema } from '@standard-community/standard-json';
function jsonSchemaToTs(schema, indent = 0) {
const spaces = " ".repeat(indent);
if ("$ref" in schema)
return "unknown";
if ("anyOf" in schema && Array.isArray(schema.anyOf)) {
const types = schema.anyOf.map((s) => jsonSchemaToTs(s, indent));
return types.join(" | ");
}
if ("oneOf" in schema && Array.isArray(schema.oneOf)) {
const types = schema.oneOf.map((s) => jsonSchemaToTs(s, indent));
return types.join(" | ");
}
if ("allOf" in schema && Array.isArray(schema.allOf)) {
const types = schema.allOf.map((s) => jsonSchemaToTs(s, indent));
return types.join(" & ");
}
if ("const" in schema)
return JSON.stringify(schema.const);
if ("enum" in schema && Array.isArray(schema.enum))
return schema.enum.map((v) => JSON.stringify(v)).join(" | ");
const type = schema.type;
if (type === "string")
return "string";
if (type === "number" || type === "integer")
return "number";
if (type === "boolean")
return "boolean";
if (type === "null")
return "null";
if (type === "array") {
const items = schema.items;
if (items)
return `${jsonSchemaToTs(items, indent)}[]`;
return "unknown[]";
}
if (type === "object" || "properties" in schema) {
const properties = schema.properties;
const required = schema.required || [];
const additionalProperties = schema.additionalProperties;
if (!properties || Object.keys(properties).length === 0) {
if (additionalProperties === false) {
return "Record<string, never>";
}
if (typeof additionalProperties === "object") {
return `Record<string, ${jsonSchemaToTs(additionalProperties, indent)}>`;
}
return "Record<string, unknown>";
}
const lines = ["{"];
const propIndent = " ".repeat(indent + 1);
for (const [key, propSchema] of Object.entries(properties)) {
const isRequired = required.includes(key);
const optional = isRequired ? "" : "?";
const safeKey = /^[\w$]+$/.test(key) && !/^\d/.test(key) ? key : JSON.stringify(key);
const propType = jsonSchemaToTs(propSchema, indent + 1);
lines.push(`${propIndent}${safeKey}${optional}: ${propType}`);
}
lines.push(`${spaces}}`);
return lines.join("\n");
}
if (Array.isArray(type)) {
const types = type.map((t) => {
switch (t) {
case "null":
return "null";
case "string":
return "string";
case "number":
case "integer":
return "number";
case "boolean":
return "boolean";
case "array":
return "unknown[]";
case "object":
return "Record<string, unknown>";
default:
return "unknown";
}
});
return types.join(" | ");
}
return "unknown";
}
function wrapTypeDeclaration(typeDeclaration, imports = "") {
return `// Auto-generated by nuxt-safe-runtime-config
// Do not edit manually
${imports}export type SafeRuntimeConfig = ${typeDeclaration}
declare global {
interface NuxtSafeRuntimeConfig extends SafeRuntimeConfig {}
}
declare module '#imports' {
export function useSafeRuntimeConfig(): SafeRuntimeConfig
}
declare module '#app' {
export function useSafeRuntimeConfig(): SafeRuntimeConfig
}
export {}
`;
}
function generateTypeDeclaration(schema) {
return wrapTypeDeclaration(jsonSchemaToTs(schema));
}
function generateSchemaTypeDeclaration(schemaPath, inference) {
const schemaSpecifier = schemaPath.replace(/\\/g, "/");
const imports = `import type { StandardSchemaV1 } from '@standard-schema/spec'
import schema from ${JSON.stringify(schemaSpecifier)}
`;
const inferredType = inference === "output" ? "InferOutput" : "InferInput";
return wrapTypeDeclaration(`StandardSchemaV1.${inferredType}<typeof schema>`, imports);
}
function reportError(msg, onError, logger, throwMsg) {
if (onError === "throw") {
logger.error(msg);
throw new Error(throwMsg ?? msg);
}
if (onError === "warn")
logger.warn(msg);
}
function resolveResult(result, onError, logger) {
const issues = "issues" in result ? result.issues : void 0;
if ("value" in result && !issues?.length)
return { success: true, value: result.value };
const errorLines = (issues ?? []).map((issue, index) => ` ${index + 1}. ${formatIssue(issue)}`);
reportError(`Validation failed!
${errorLines.join("\n")}`, onError, logger, "Runtime config validation failed");
return { success: false };
}
function isPromiseLike(value) {
return Boolean(value && typeof value.then === "function");
}
function validateRuntimeConfig(config, schema, onError, logger) {
if (!isStandardSchema(schema)) {
reportError("Schema is not Standard Schema compatible", onError, logger, "Invalid schema format");
return { success: false };
}
const result = schema["~standard"].validate(config);
return isPromiseLike(result) ? result.then((value) => resolveResult(value, onError, logger)) : resolveResult(result, onError, logger);
}
function isStandardSchema(schema) {
const candidate = schema;
return Boolean(
candidate && (typeof schema === "object" || typeof schema === "function") && candidate["~standard"] && typeof candidate["~standard"] === "object" && typeof candidate["~standard"].validate === "function"
);
}
function formatIssue(issue) {
const path = issue.path ? issue.path.map((p) => typeof p === "object" && p !== null && "key" in p ? p.key : p).join(".") : "root";
return `${path}: ${issue.message || "Validation error"}`;
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
function getNativeJSONSchemaOutput(schema) {
const output = schema["~standard"]?.jsonSchema?.output;
return typeof output === "function" ? output : void 0;
}
async function getJSONSchema(schema, target = "draft-2020-12", onFallback) {
const nativeOutput = getNativeJSONSchemaOutput(schema);
if (nativeOutput) {
try {
return await nativeOutput({ target });
} catch (error) {
onFallback?.(`Native JSON Schema failed for target "${target}" (${errorMessage(error)}), using @standard-community/standard-json fallback`);
}
}
return await toJsonSchema(schema);
}
async function resolveValidationOptions(options = {}, rootDir = process.cwd()) {
let schema = options.$schema;
let schemaPath = options.schemaPath;
if (typeof schema === "string") {
const jiti = createJiti(join(rootDir, "nuxt-safe-runtime-config.loader.mjs"));
const resolvedPath = jiti.esmResolve(schema);
schemaPath = resolvedPath.startsWith("file:") ? fileURLToPath(resolvedPath) : resolvedPath;
schema = await jiti.import(schemaPath, { default: true });
if (!isStandardSchema(schema))
throw new TypeError(`Schema module must default export a Standard Schema: ${schemaPath}`);
}
return {
$schema: schema,
schemaPath,
validateAtBuild: options.validateAtBuild ?? true,
validateAtRuntime: options.validateAtRuntime ?? false,
jsonSchemaTarget: options.jsonSchemaTarget ?? "draft-2020-12",
onError: options.onError ?? "throw"
};
}
function detectJsonSchemaDraft(schemaUri) {
if (schemaUri?.includes("2019-09"))
return "2019-09";
if (schemaUri?.includes("draft-07"))
return "7";
return "2020-12";
}
async function createRuntimeValidationArtifacts(options, warn) {
if (!options.$schema)
return null;
const jsonSchema = options.schemaPath ? {} : await getJSONSchema(options.$schema, options.jsonSchemaTarget, warn);
const draft = detectJsonSchemaDraft(jsonSchema.$schema);
const runtimeSchema = options.schemaPath ? `import runtimeSchema from '#safe-runtime-config/runtime-schema'
` : "const runtimeSchema = null\n";
return {
draft,
jsonSchema,
typeDeclaration: options.schemaPath ? generateSchemaTypeDeclaration(options.schemaPath, options.validateAtRuntime ? "output" : "input") : generateTypeDeclaration(jsonSchema),
validateTemplate: `${runtimeSchema}export { runtimeSchema }
export const schema = ${JSON.stringify(jsonSchema)}
export const onError = ${JSON.stringify(options.onError)}
export const draft = ${JSON.stringify(draft)}
`,
validateTemplateDeclaration: `import type { Schema, SchemaDraft } from '@cfworker/json-schema'
import type { StandardSchemaV1 } from '@standard-schema/spec'
export declare const runtimeSchema: StandardSchemaV1 | null
export declare const schema: Schema
export declare const onError: 'throw' | 'warn' | 'ignore'
export declare const draft: SchemaDraft
`
};
}
function toCamelCase(str) {
return str.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
}
function getPrefix(key) {
const parts = key.split("_");
return parts.length > 1 ? parts[0] : null;
}
function countPrefixes(keys) {
const counts = /* @__PURE__ */ new Map();
for (const key of keys) {
const prefix = getPrefix(key);
if (prefix)
counts.set(prefix, (counts.get(prefix) || 0) + 1);
}
return counts;
}
function resolveConfigPath(key, prefixCounts) {
if (key.startsWith("PUBLIC_"))
return ["public", toCamelCase(key.slice(7))];
if (key.startsWith("NUXT_PUBLIC_"))
return ["public", toCamelCase(key.slice(12))];
const prefix = getPrefix(key);
if (prefix && (prefixCounts.get(prefix) || 0) >= 2) {
return [toCamelCase(prefix), toCamelCase(key.slice(prefix.length + 1))];
}
return [toCamelCase(key)];
}
function assignPath(target, path, value) {
if (path.length === 1) {
target[path[0]] = value;
return;
}
const [head, ...tail] = path;
target[head] ??= {};
assignPath(target[head], tail, value);
}
function transformEnvVars(vars) {
const result = {};
const prefixCounts = countPrefixes(vars.map((v) => v.key));
for (const { key, value } of vars) {
const path = resolveConfigPath(key, prefixCounts);
assignPath(result, path, value);
}
return result;
}
const logger = consola.withTag("safe-runtime-config");
function resolveRuntimeFile(name) {
const candidates = [
`./runtime/nitro/${name}.ts`,
`./runtime/nitro/${name}.js`,
`../runtime/nitro/${name}.js`
];
for (const candidate of candidates) {
const path = fileURLToPath(new URL(candidate, import.meta.url));
if (existsSync(path))
return path;
}
return fileURLToPath(new URL(candidates[2], import.meta.url));
}
const RUNTIME_PLUGIN_PATH = resolveRuntimeFile("validate-plugin");
const RUNTIME_MIDDLEWARE_PATH = resolveRuntimeFile("validate-middleware");
function resolveRuntimeConfigImport(majorVersion, parentPath) {
const specifier = majorVersion >= 3 ? "nitro/runtime-config" : "nitropack/runtime/config";
const resolved = createJiti(parentPath).esmResolve(specifier);
return resolved.startsWith("file:") ? fileURLToPath(resolved) : resolved;
}
async function writeFileIfChanged(file, contents) {
try {
if (await readFile(file, "utf8") === contents)
return;
} catch (error) {
if (error.code !== "ENOENT")
throw error;
}
await mkdir(dirname(file), { recursive: true });
await writeFile(file, contents);
}
async function writeArtifacts(nitro, artifacts) {
const outDir = join(nitro.options.buildDir, "safe-runtime-config");
await Promise.all([
writeFileIfChanged(join(outDir, "validate.mjs"), artifacts.validateTemplate),
writeFileIfChanged(join(outDir, "validate.d.ts"), artifacts.validateTemplateDeclaration),
writeFileIfChanged(join(nitro.options.buildDir, "types/safe-runtime-config.d.ts"), artifacts.typeDeclaration)
]);
}
function pushUnique(list, item) {
if (!list.includes(item))
list.push(item);
}
function orderRuntimePlugins(list) {
const shelvePluginPattern = /[\\/]safe-runtime-config[\\/]shelve-plugin(?:\.[cm]?[jt]s)?$/;
const shelvePlugins = list.filter((plugin) => shelvePluginPattern.test(plugin));
const userPlugins = list.filter((plugin) => plugin !== RUNTIME_PLUGIN_PATH && !shelvePluginPattern.test(plugin));
list.splice(0, list.length, ...shelvePlugins, RUNTIME_PLUGIN_PATH, ...userPlugins);
}
function configureNitro(nitro, options, validateModule, typeDeclaration) {
nitro.options.alias ||= {};
nitro.options.alias["#safe-runtime-config/validate"] = validateModule;
if (options.schemaPath)
nitro.options.alias["#safe-runtime-config/runtime-schema"] = options.schemaPath;
const ts = nitro.options.typescript ||= {};
ts.tsConfig ||= {};
ts.tsConfig.include ||= [];
pushUnique(ts.tsConfig.include, `./${relative(nitro.options.buildDir, typeDeclaration)}`);
if (options.validateAtRuntime) {
nitro.options.alias["#safe-runtime-config/nitro-runtime-config"] ||= resolveRuntimeConfigImport(
nitro.meta?.majorVersion ?? 2,
join(nitro.options.rootDir, "nitro.config.ts")
);
nitro.options.plugins ||= [];
orderRuntimePlugins(nitro.options.plugins);
if (options.schemaPath) {
nitro.options.handlers ||= [];
if (!nitro.options.handlers.some((handler) => handler.handler === RUNTIME_MIDDLEWARE_PATH))
nitro.options.handlers.unshift({ route: "/", handler: RUNTIME_MIDDLEWARE_PATH, middleware: true });
}
}
}
const safeRuntimeConfigNitroModule = {
name: "nuxt-safe-runtime-config/nitro",
async setup(nitro) {
const options = await resolveValidationOptions(nitro.options.safeRuntimeConfig, nitro.options.rootDir);
if (!options.$schema)
return;
const artifacts = await createRuntimeValidationArtifacts(options, (msg) => logger.warn(msg));
if (!artifacts)
return;
const applyArtifacts = async () => {
const validateModule = join(nitro.options.buildDir, "safe-runtime-config/validate.mjs");
const typeDeclaration = join(nitro.options.buildDir, "types/safe-runtime-config.d.ts");
await writeArtifacts(nitro, artifacts);
configureNitro(nitro, options, validateModule, typeDeclaration);
};
await applyArtifacts();
nitro.hooks.hook("rollup:before", applyArtifacts);
if (options.validateAtBuild) {
nitro.hooks.hook("build:before", async () => {
await applyArtifacts();
await validateRuntimeConfig(nitro.options.runtimeConfig, options.$schema, options.onError, logger);
});
}
}
};
export { RUNTIME_MIDDLEWARE_PATH, RUNTIME_PLUGIN_PATH, safeRuntimeConfigNitroModule as default, resolveRuntimeConfigImport, transformEnvVars };