UNPKG

nuxt-safe-runtime-config

Version:

Validate Nuxt runtime config with Standard Schema at build time

386 lines (371 loc) 14.6 kB
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 };