release: WRNexusJS 0.8.0
This commit is contained in:
+203
-10
@@ -8,14 +8,20 @@
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { dirname, extname, join, resolve } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { PerformanceBudgets, SecurityConfig, SeoConfig } from "@wrnexus/core";
|
||||
import type { PluginInput } from "@wrnexus/plugin";
|
||||
import type { PluginInput, PluginPermission } from "@wrnexus/plugin";
|
||||
import type { StorageConfig } from "@wrnexus/uploader";
|
||||
import type { ThemeConfig } from "./theme.ts";
|
||||
import type { FontConfig } from "./fonts.ts";
|
||||
import { fontCspSources } from "./fonts.ts";
|
||||
import {
|
||||
isCompatibilityDate,
|
||||
resolveCompatibility,
|
||||
type CompatibilityPolicy,
|
||||
} from "./compatibility.ts";
|
||||
|
||||
export type Mode = "development" | "production";
|
||||
|
||||
@@ -120,6 +126,15 @@ export interface PwaConfig {
|
||||
cacheUrls?: string[];
|
||||
/** Service-worker cache key. Change it to invalidate existing PWA caches. */
|
||||
cacheName?: string;
|
||||
/** Ordered URL rules for runtime caching. Patterns are regular-expression source strings. */
|
||||
runtimeCaching?: Array<{
|
||||
pattern: string;
|
||||
strategy: "network-first" | "cache-first" | "stale-while-revalidate";
|
||||
cacheName?: string;
|
||||
methods?: string[];
|
||||
}>;
|
||||
/** Background Sync tag used by the offline mutation queue. */
|
||||
backgroundSyncTag?: string;
|
||||
}
|
||||
|
||||
export type DevToolbarPosition = "bottom-center" | "bottom-left" | "bottom-right";
|
||||
@@ -229,9 +244,16 @@ export interface CompatibilityConfig {
|
||||
stringLayouts?: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
export interface AppConfig extends CompatibilityPolicy {
|
||||
/** Ordered reusable configuration layers; the application always has final precedence. */
|
||||
extends?: string | string[];
|
||||
/** Compiler/dev/build plugins, resolved in deterministic pre/normal/post order. */
|
||||
plugins?: PluginInput;
|
||||
/** Optional least-privilege enforcement for automatically discovered packages. */
|
||||
pluginPermissions?: {
|
||||
enforce?: boolean;
|
||||
grants?: Record<string, PluginPermission[]>;
|
||||
};
|
||||
/** WRN v0.6 explicit import and compatibility resolution. */
|
||||
imports?: ImportsConfig;
|
||||
/** TypeScript-backed .wrn type checking and declaration generation. */
|
||||
@@ -276,7 +298,21 @@ export interface AppConfig {
|
||||
/** Design-token themes (deep-merged over the built-in light/dark). */
|
||||
theme?: ThemeConfig;
|
||||
/** i18n: default language + supported locales (strings live in app/locales/*.json). */
|
||||
i18n?: { default?: string; locales?: string[] };
|
||||
i18n?: {
|
||||
default?: string;
|
||||
locales?: string[];
|
||||
labels?: Record<string, string>;
|
||||
fallbacks?: Record<string, string[]>;
|
||||
direction?: Record<string, "ltr" | "rtl">;
|
||||
cookie?: {
|
||||
name?: string;
|
||||
maxAge?: number;
|
||||
path?: string;
|
||||
sameSite?: "Strict" | "Lax" | "None";
|
||||
secure?: boolean;
|
||||
};
|
||||
strict?: boolean;
|
||||
};
|
||||
/** Default database connection (driver + url); reached with `getDb()`. */
|
||||
db?: { driver: "sqlite" | "postgres" | "mysql" | "mongo"; url: string };
|
||||
/**
|
||||
@@ -330,15 +366,116 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
/** Deep-merge `override` onto `base` (objects merge; arrays/scalars replace). */
|
||||
function deepMerge<T>(base: T, override: unknown): T {
|
||||
function deepMerge<T>(base: T, override: unknown, path = ""): T {
|
||||
if (!isPlainObject(base) || !isPlainObject(override)) return (override ?? base) as T;
|
||||
const out: Record<string, unknown> = { ...base };
|
||||
for (const [key, value] of Object.entries(override)) {
|
||||
out[key] = key in out ? deepMerge(out[key], value) : value;
|
||||
const currentPath = path ? `${path}.${key}` : key;
|
||||
if (
|
||||
key in out &&
|
||||
["plugins", "head"].includes(currentPath) &&
|
||||
(Array.isArray(out[key]) || Array.isArray(value))
|
||||
) {
|
||||
const before = Array.isArray(out[key]) ? out[key] : out[key] == null ? [] : [out[key]];
|
||||
const after = Array.isArray(value) ? value : value == null ? [] : [value];
|
||||
out[key] = [...before, ...after];
|
||||
} else {
|
||||
out[key] = key in out ? deepMerge(out[key], value, currentPath) : value;
|
||||
}
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
|
||||
const LAYER_CONFIG_NAMES = [
|
||||
"wrnexus.layer.ts",
|
||||
"wrnexus.layer.js",
|
||||
"wrnexus.layer.mjs",
|
||||
...CONFIG_NAMES,
|
||||
];
|
||||
|
||||
function layerSpecifiers(config: AppConfig): string[] {
|
||||
return config.extends ? (Array.isArray(config.extends) ? config.extends : [config.extends]) : [];
|
||||
}
|
||||
|
||||
function resolveLayerFile(specifier: string, declaringRoot: string, appRoot: string): string {
|
||||
const local =
|
||||
specifier.startsWith(".") || specifier.startsWith("/") || /^[A-Za-z]:[\\/]/.test(specifier);
|
||||
if (local) {
|
||||
const candidate = resolve(declaringRoot, specifier);
|
||||
if (extname(candidate) && existsSync(candidate)) return candidate;
|
||||
for (const name of LAYER_CONFIG_NAMES) {
|
||||
const file = join(candidate, name);
|
||||
if (existsSync(file)) return file;
|
||||
}
|
||||
throw new Error(`WRN-CONFIG-LAYER-NOT-FOUND: ${specifier} from ${declaringRoot}.`);
|
||||
}
|
||||
const require = createRequire(join(appRoot, "package.json"));
|
||||
try {
|
||||
const packageFile = require.resolve(`${specifier}/package.json`);
|
||||
const packageRoot = dirname(packageFile);
|
||||
const pkg = JSON.parse(readFileSync(packageFile, "utf8")) as {
|
||||
wrnexus?: { layer?: string };
|
||||
};
|
||||
if (pkg.wrnexus?.layer) {
|
||||
const file = resolve(packageRoot, pkg.wrnexus.layer);
|
||||
if (existsSync(file)) return file;
|
||||
}
|
||||
for (const name of LAYER_CONFIG_NAMES) {
|
||||
const file = join(packageRoot, name);
|
||||
if (existsSync(file)) return file;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`WRN-CONFIG-LAYER-NOT-FOUND: package ${specifier} from ${appRoot}.`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw new Error(`WRN-CONFIG-LAYER-ENTRY: ${specifier} has no wrnexus layer entry.`);
|
||||
}
|
||||
|
||||
async function importConfigFile(file: string): Promise<AppConfig> {
|
||||
const mod = (await import(pathToFileURL(file).href)) as { default?: AppConfig };
|
||||
if (!mod.default || !isPlainObject(mod.default)) {
|
||||
throw new Error(`WRN-CONFIG-LAYER-SHAPE: ${file} must export a configuration object.`);
|
||||
}
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
export interface ResolvedConfigLayers {
|
||||
config: AppConfig;
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
export async function resolveConfigLayers(
|
||||
appRoot: string,
|
||||
application: AppConfig,
|
||||
): Promise<ResolvedConfigLayers> {
|
||||
const sources: string[] = [];
|
||||
const visiting: string[] = [];
|
||||
const resolvedFiles = new Set<string>();
|
||||
const visit = async (config: AppConfig, declaringRoot: string): Promise<AppConfig> => {
|
||||
let merged: AppConfig = {};
|
||||
for (const specifier of layerSpecifiers(config)) {
|
||||
const file = resolveLayerFile(specifier, declaringRoot, appRoot);
|
||||
if (visiting.includes(file)) {
|
||||
throw new Error(`WRN-CONFIG-LAYER-CYCLE: ${[...visiting, file].join(" -> ")}`);
|
||||
}
|
||||
visiting.push(file);
|
||||
const layer = await importConfigFile(file);
|
||||
const resolvedLayer = await visit(layer, dirname(file));
|
||||
visiting.pop();
|
||||
merged = deepMerge(merged, resolvedLayer);
|
||||
if (!resolvedFiles.has(file)) {
|
||||
resolvedFiles.add(file);
|
||||
sources.push(file);
|
||||
}
|
||||
}
|
||||
const own = { ...config };
|
||||
delete own.extends;
|
||||
return deepMerge(merged, own);
|
||||
};
|
||||
return { config: await visit(application, appRoot), sources };
|
||||
}
|
||||
|
||||
/** Load the raw `wrnexus.config.*` (with the `profiles` map intact), or `{}`. */
|
||||
export async function loadRawConfig(appRoot: string): Promise<AppConfig> {
|
||||
for (const name of CONFIG_NAMES) {
|
||||
@@ -358,10 +495,12 @@ export async function loadAppConfig(appRoot: string, profile?: string): Promise<
|
||||
// module evaluation. Load the profile cascade before importing the module so
|
||||
// development and production behave consistently.
|
||||
loadEnv(appRoot, active);
|
||||
const base = await loadRawConfig(appRoot);
|
||||
const raw = await loadRawConfig(appRoot);
|
||||
const { config: base } = await resolveConfigLayers(appRoot, raw);
|
||||
const override = base.profiles?.[active];
|
||||
const merged: AppConfig = override ? deepMerge(base, override) : { ...base };
|
||||
delete merged.profiles;
|
||||
delete merged.extends;
|
||||
applyFontCsp(merged);
|
||||
const issues = validateAppConfig(merged);
|
||||
const errors = issues.filter((issue) => issue.severity === "error");
|
||||
@@ -451,14 +590,66 @@ export function defineConfig(config: AppConfig): AppConfig {
|
||||
|
||||
export function validateAppConfig(config: AppConfig): ConfigIssue[] {
|
||||
const issues: ConfigIssue[] = [];
|
||||
if (config.compatibilityDate !== undefined && !isCompatibilityDate(config.compatibilityDate)) {
|
||||
issues.push({
|
||||
path: "compatibilityDate",
|
||||
severity: "error",
|
||||
message: "must be a real ISO calendar date in YYYY-MM-DD format",
|
||||
});
|
||||
}
|
||||
if (
|
||||
config.frameworkBehaviour !== undefined &&
|
||||
(!Number.isInteger(config.frameworkBehaviour) || config.frameworkBehaviour < 1)
|
||||
) {
|
||||
issues.push({
|
||||
path: "frameworkBehaviour",
|
||||
severity: "error",
|
||||
message: "must be a positive integer",
|
||||
});
|
||||
}
|
||||
const compatibility = resolveCompatibility(config);
|
||||
if (compatibility.future) {
|
||||
issues.push({
|
||||
path: "compatibilityDate",
|
||||
severity: "error",
|
||||
message: "targets framework behavior newer than this version supports",
|
||||
});
|
||||
}
|
||||
const sampleRate = config.observability?.sampleRate;
|
||||
if (sampleRate !== undefined && (sampleRate < 0 || sampleRate > 1)) {
|
||||
if (
|
||||
sampleRate !== undefined &&
|
||||
(!Number.isFinite(sampleRate) || sampleRate < 0 || sampleRate > 1)
|
||||
) {
|
||||
issues.push({
|
||||
path: "observability.sampleRate",
|
||||
severity: "error",
|
||||
message: "must be between 0 and 1",
|
||||
});
|
||||
}
|
||||
if (config.observability?.exporter === "otlp") {
|
||||
const endpoint = config.observability.endpoint;
|
||||
const validEndpoint = (() => {
|
||||
try {
|
||||
return Boolean(endpoint && ["http:", "https:"].includes(new URL(endpoint).protocol));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
if (!validEndpoint) {
|
||||
issues.push({
|
||||
path: "observability.endpoint",
|
||||
severity: "error",
|
||||
message: "must be an absolute HTTP(S) URL when exporter is otlp",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (config.observability?.serviceName !== undefined && !config.observability.serviceName.trim()) {
|
||||
issues.push({
|
||||
path: "observability.serviceName",
|
||||
severity: "error",
|
||||
message: "must not be empty",
|
||||
});
|
||||
}
|
||||
const budgets = config.performance?.budgets;
|
||||
if (budgets) {
|
||||
for (const [name, value] of Object.entries(budgets)) {
|
||||
@@ -494,7 +685,9 @@ export async function explainAppConfig(
|
||||
): Promise<ExplainedConfig> {
|
||||
const active = profile ?? resolveProfile();
|
||||
const config = await loadAppConfig(appRoot, active);
|
||||
const sources = CONFIG_NAMES.filter((name) => existsSync(join(appRoot, name)));
|
||||
const appSources = CONFIG_NAMES.filter((name) => existsSync(join(appRoot, name)));
|
||||
const raw = await loadRawConfig(appRoot);
|
||||
const layerSources = (await resolveConfigLayers(appRoot, raw)).sources;
|
||||
const envSources = [".env", ".env.local", `.env.${active}`, `.env.${active}.local`].filter(
|
||||
(name) => existsSync(join(appRoot, name)),
|
||||
);
|
||||
@@ -502,7 +695,7 @@ export async function explainAppConfig(
|
||||
profile: active,
|
||||
config,
|
||||
issues: validateAppConfig(config),
|
||||
sources: [...sources, ...envSources],
|
||||
sources: [...layerSources, ...appSources, ...envSources],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user