release: WRNexusJS 0.2.29

This commit is contained in:
2026-07-14 17:34:51 +05:30
parent e27c92e3b6
commit e6dbb5b0fc
62 changed files with 513 additions and 292 deletions
+214 -16
View File
@@ -19,17 +19,44 @@ import type { GatewayAuth, GatewaySecurity } from "@wrnexus/dev-server";
const frameworkVersion = currentCliVersion();
export type WorkspaceEnvironment = "development" | "staging" | "production" | (string & {});
export interface WorkspaceEnvironmentConfig {
protocol?: "http" | "https";
rootDomain?: string;
port?: number;
}
export interface WorkspaceApp {
name: string;
dir: string;
domains: string[];
/**
* Generates <subdomain>.<environment.rootDomain>.
* Example: subdomain "sso" + rootDomain "local.wrnexus.test".
*/
subdomain?: string;
/**
* Explicit hostname overrides by environment.
*/
domains?: string[] | Partial<Record<WorkspaceEnvironment, string | string[]>>;
/**
* Complete public URL override by environment.
*/
urls?: Partial<Record<WorkspaceEnvironment, string>>;
port?: number;
/** Per-app access control enforced at the gateway (basic auth, IP allowlist, forward-auth). */
auth?: GatewayAuth;
}
export interface WorkspaceConfig {
defaultEnvironment?: WorkspaceEnvironment;
environments?: Record<WorkspaceEnvironment, WorkspaceEnvironmentConfig>;
apps: WorkspaceApp[];
/** Gateway-wide security (trusted hosts, rate limit, headers, access log). */
security?: GatewaySecurity;
}
@@ -263,7 +290,13 @@ export async function addWorkspaceApp(root: string, name: string, args: string[]
if (config.apps.some((app) => app.name === name || app.dir.replace(/\\/g, "/") === dir)) {
throw new Error(`Workspace app '${name}' already exists.`);
}
if (config.apps.some((app) => app.domains.includes(domain))) {
const normalizedDomain = domain.toLowerCase();
if (
config.apps.some((app) =>
configuredDomains(app).some((configured) => configured.toLowerCase() === normalizedDomain),
)
) {
throw new Error(`Workspace domain '${domain}' is already assigned.`);
}
const appRoot = join(resolvedRoot, "apps", name);
@@ -285,6 +318,24 @@ export async function addWorkspaceApp(root: string, name: string, args: string[]
console.log("\nRun `bun install`, then `bun run dev`.");
}
function configuredDomains(app: WorkspaceApp): string[] {
if (!app.domains) {
return [];
}
if (Array.isArray(app.domains)) {
return app.domains;
}
return Object.values(app.domains).flatMap((value) => {
if (!value) {
return [];
}
return Array.isArray(value) ? value : [value];
});
}
/** Scaffold a monorepo workspace with two starter apps + a shared package. */
export function createWorkspace(name: string): void {
if (!name) {
@@ -329,24 +380,171 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
/** Run the multi-app gateway from `wrnexus.workspace.ts`. */
export async function runGateway(root: string, args: string[]): Promise<void> {
const config = await loadWorkspaceConfig(resolve(root));
const portArg = args.find((a) => a.startsWith("--port="));
const hostArg = args.find((a) => a.startsWith("--host="));
const port = portArg ? Number(portArg.split("=")[1]) : 3000;
const portArg = args.find((arg) => arg.startsWith("--port="));
const hostArg = args.find((arg) => arg.startsWith("--host="));
const environmentArg = args.find((arg) => arg.startsWith("--environment="));
const profileArg = args.find((arg) => arg.startsWith("--profile="));
const environment =
environmentArg?.split("=")[1] ||
profileArg?.split("=")[1] ||
process.env.WRNEXUS_ENV ||
process.env.WRNEXUS_PROFILE ||
config.defaultEnvironment ||
(args.includes("--prod") ? "production" : "development");
const resolved = resolveWorkspaceConfig(config, environment);
const port = portArg
? Number(portArg.split("=")[1])
: resolved.environment === "development"
? 3000
: 80;
const hostname = hostArg?.split("=")[1];
const mode = args.includes("--prod") ? "production" : "development";
const { startGateway } = await import("@wrnexus/dev-server");
await startGateway({
port,
hostname,
mode,
security: config.security,
apps: config.apps.map((a) => ({
name: a.name,
dir: resolve(root, a.dir),
domains: a.domains,
port: a.port,
auth: a.auth,
mode: environment === "production" ? "production" : "development",
environment,
security: resolved.security,
apps: resolved.apps.map((app) => ({
name: app.name,
dir: resolve(root, app.dir),
domains: app.domains,
publicOrigin: app.publicOrigin,
port: app.port,
auth: app.auth,
})),
});
}
function environmentVariableName(appName: string): string {
return `WRNEXUS_APP_${appName.replace(/[^A-Za-z0-9]/g, "_").toUpperCase()}_URL`;
}
function normalizeOrigin(value: string): string {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`Application URL must use http or https: ${value}`);
}
return url.origin;
}
function valuesForEnvironment(
value: WorkspaceApp["domains"],
environment: WorkspaceEnvironment,
): string[] {
if (!value) return [];
if (Array.isArray(value)) {
return value;
}
const selected = value[environment];
if (!selected) return [];
return Array.isArray(selected) ? selected : [selected];
}
export interface ResolvedWorkspaceApp extends WorkspaceApp {
domains: string[];
publicOrigin: string;
}
export interface ResolvedWorkspaceConfig {
environment: WorkspaceEnvironment;
apps: ResolvedWorkspaceApp[];
security?: GatewaySecurity;
}
export function resolveWorkspaceConfig(
config: WorkspaceConfig,
environment: WorkspaceEnvironment,
): ResolvedWorkspaceConfig {
const environmentConfig = config.environments?.[environment] ?? {};
const protocol = environmentConfig.protocol ?? (environment === "production" ? "https" : "http");
const configuredPort = environmentConfig.port;
const apps = config.apps.map((app) => {
const envOverride = process.env[environmentVariableName(app.name)];
const configuredUrl = app.urls?.[environment];
const explicitDomains = valuesForEnvironment(app.domains, environment);
const generatedDomain =
app.subdomain && environmentConfig.rootDomain
? `${app.subdomain}.${environmentConfig.rootDomain}`
: undefined;
const domains =
explicitDomains.length > 0 ? explicitDomains : generatedDomain ? [generatedDomain] : [];
let publicOrigin: string;
if (envOverride) {
publicOrigin = normalizeOrigin(envOverride);
} else if (configuredUrl) {
publicOrigin = normalizeOrigin(configuredUrl);
} else if (domains[0]) {
const port =
configuredPort &&
!(
(protocol === "http" && configuredPort === 80) ||
(protocol === "https" && configuredPort === 443)
)
? `:${configuredPort}`
: "";
publicOrigin = `${protocol}://${domains[0]}${port}`;
} else {
throw new Error(
`No domain or URL configured for workspace app "${app.name}" in environment "${environment}".`,
);
}
const publicUrl = new URL(publicOrigin);
return {
...app,
domains: domains.length > 0 ? domains : [publicUrl.hostname],
publicOrigin,
};
});
const usedDomains = new Map<string, string>();
for (const app of apps) {
for (const domain of app.domains) {
const normalized = domain.toLowerCase();
const owner = usedDomains.get(normalized);
if (owner) {
throw new Error(
`Workspace domain "${domain}" is assigned to both "${owner}" and "${app.name}".`,
);
}
usedDomains.set(normalized, app.name);
}
}
return {
environment,
apps,
security: config.security,
};
}