/** * Monorepo support: * - `wrnexus workspace ` scaffolds a multi-app workspace (apps/* + shared packages/*) * - `wrnexus workspace add --domain=` adds an app to the current workspace * - `wrnexus gateway [--port]` serves every app behind one port, routed by domain * * A workspace holds several WrNexus apps under `apps/*` and shared libraries under * `packages/*`. Apps share code by importing a workspace package (e.g. `@app/shared`), * share databases, and talk at runtime via @wrnexus/pubsub (Redis driver for * cross-process). `wrnexus.workspace.ts` maps each app to the domains it serves. */ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { scaffoldApp } from "./create.ts"; import { currentCliVersion } from "./update-notifier.ts"; 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; /** * Generates .. * Example: subdomain "sso" + rootDomain "local.wrnexus.test". */ subdomain?: string; /** * Explicit hostname overrides by environment. */ domains?: string[] | Partial>; /** * Complete public URL override by environment. */ urls?: Partial>; port?: number; auth?: GatewayAuth; } export interface WorkspaceConfig { defaultEnvironment?: WorkspaceEnvironment; environments?: Record; apps: WorkspaceApp[]; security?: GatewaySecurity; } export const workspaceFiles = (name: string): Record => ({ "package.json": `{ "name": "${name}", "private": true, "type": "module", "workspaces": ["apps/*", "packages/*"], "scripts": { "dev": "wrnexus gateway", "gateway": "wrnexus gateway" }, "devDependencies": { "@wrnexus/cli": "${frameworkVersion}" } } `, "wrnexus.workspace.ts": `import type { WorkspaceConfig } from "@wrnexus/cli/workspace"; // Map each app to the domains it serves. \`wrnexus gateway\` runs them all behind // one port and routes by Host header (add these hosts to your /etc/hosts). const config: WorkspaceConfig = { // Gateway-wide security (all optional): security: { trustedHostsOnly: true, // reject requests for unknown domains rateLimit: { max: 300, windowMs: 60_000 }, // per client IP headers: true, // baseline security headers at the edge accessLog: true, // log host → app, method, path, status }, apps: [ { name: "web", dir: "apps/web", domains: ["localhost", "web.localhost"] }, { name: "admin", dir: "apps/admin", domains: ["admin.localhost"], // Lock the admin app down at the edge (pick one): auth: { basic: { user: "admin", pass: "change-me" } }, // auth: { allowIps: ["127.0.0.1", "::1"] }, // auth: { forward: { url: "http://localhost:4001/api/verify" } }, // SSO }, ], }; export default config; `, ".gitignore": `node_modules/ dist/ .wrnexus/ *.log *.db `, "packages/shared/package.json": `{ "name": "@app/shared", "version": "0.0.0", "private": true, "type": "module", "main": "src/index.ts", "exports": { ".": "./src/index.ts" }, "dependencies": { "@wrnexus/pubsub": "${frameworkVersion}" } } `, "packages/shared/src/index.ts": `/** * Shared code for every app in this workspace. Import it anywhere: \`@app/shared\`. * The cross-app event bus uses Redis so messages reach every app process/domain. */ import { createPubSub } from "@wrnexus/pubsub"; import { redisDriver } from "@wrnexus/pubsub/redis"; // One bus per process, backed by Redis (set REDIS_URL, defaults to localhost:6379). export const bus = createPubSub(redisDriver(process.env.REDIS_URL)); // Shared domain types can live here and be imported by every app. export interface Tenant { id: string; name: string; } `, "README.md": `# ${name} A WrNexus **workspace** — multiple apps, one gateway, interconnected. \`\`\` ${name}/ wrnexus.workspace.ts # apps ↔ domains map (used by \`wrnexus gateway\`) apps/ web/ # a WrNexus app → localhost, web.localhost admin/ # a WrNexus app → admin.localhost packages/ shared/ # @app/shared — shared code + cross-app pubsub bus \`\`\` ## Run everything (one port, routed by domain) \`\`\`bash bun install bun run dev # = wrnexus gateway → http://127.0.0.1:3000 \`\`\` Add the hosts to your machine (e.g. /etc/hosts): \`\`\` 127.0.0.1 web.localhost admin.localhost \`\`\` Open \`http://localhost:3000\` for the web app or \`http://admin.localhost:3000\` for the admin app. The ports printed for individual apps are internal gateway targets, not public workspace URLs. ## Interconnect - **Shared code:** import \`@app/shared\` in any app. - **Runtime messaging:** \`import { bus } from "@app/shared"\` then \`bus.publish("tenant:created", {...})\` in one app and \`bus.subscribe("tenant:*", fn)\` in another (needs Redis). - **Databases:** point apps at the same \`db\`/\`databases\` in their config. ## Add another app \`\`\`bash wrnexus workspace add reports --domain=reports.localhost \`\`\` `, }); const workspaceConfigNames = [ "wrnexus.workspace.ts", "wrnexus.workspace.js", "wrnexus.workspace.mjs", ] as const; function workspaceConfigPath(root: string): string | null { for (const file of workspaceConfigNames) { const path = join(root, file); if (existsSync(path)) return path; } return null; } /** Insert an app entry into the top-level `apps: [...]` array. */ export function insertWorkspaceApp(source: string, app: WorkspaceApp): string { const declaration = /\bapps\s*:\s*\[/.exec(source); if (!declaration) throw new Error("Workspace config has no `apps: [...]` array."); const start = source.indexOf("[", declaration.index); let depth = 0; let quote = ""; let escaped = false; let lineComment = false; let blockComment = false; let end = -1; for (let i = start; i < source.length; i++) { const char = source[i]!; const next = source[i + 1] ?? ""; if (lineComment) { if (char === "\n") lineComment = false; continue; } if (blockComment) { if (char === "*" && next === "/") { blockComment = false; i++; } continue; } if (quote) { if (escaped) escaped = false; else if (char === "\\") escaped = true; else if (char === quote) quote = ""; continue; } if (char === "/" && next === "/") { lineComment = true; i++; continue; } if (char === "/" && next === "*") { blockComment = true; i++; continue; } if (char === '"' || char === "'" || char === "`") { quote = char; continue; } if (char === "[") depth++; if (char === "]" && --depth === 0) { end = i; break; } } if (end < 0) throw new Error("Workspace `apps` array is not closed."); const serialized = `{ name: ${JSON.stringify(app.name)}, dir: ${JSON.stringify(app.dir)}, domains: ${JSON.stringify(app.domains)}${app.port ? `, port: ${app.port}` : ""} }`; if (!source.slice(start, end).includes("\n")) { const contents = source.slice(start + 1, end).trimEnd(); const separator = contents && !contents.endsWith(",") ? "," : ""; return source.slice(0, end) + `${separator} ${serialized}` + source.slice(end); } const lineStart = source.lastIndexOf("\n", end - 1) + 1; const closeIndent = /^\s*/.exec(source.slice(lineStart, end))?.[0] ?? ""; const entry = `${closeIndent} ${serialized},\n`; return source.slice(0, lineStart) + entry + source.slice(lineStart); } /** Add a scaffolded app and register its domain in the current workspace. */ export async function addWorkspaceApp(root: string, name: string, args: string[]): Promise { if (!/^[a-z][a-z0-9-]*$/.test(name)) { throw new Error( "App name must start with a letter and contain only lowercase letters, digits, and hyphens.", ); } const resolvedRoot = resolve(root); const configPath = workspaceConfigPath(resolvedRoot); if (!configPath) throw new Error("No wrnexus.workspace.ts found in the current directory."); const domain = args.find((arg) => arg.startsWith("--domain="))?.split("=")[1] || `${name}.localhost`; if (!/^[a-z0-9.-]+$/.test(domain)) throw new Error(`Invalid workspace domain: ${domain}`); const portValue = args.find((arg) => arg.startsWith("--port="))?.split("=")[1]; const port = portValue ? Number(portValue) : undefined; if (portValue && (!Number.isInteger(port) || port! < 1 || port! > 65535)) { throw new Error(`Invalid app port: ${portValue}`); } const config = await loadWorkspaceConfig(resolvedRoot); const dir = `apps/${name}`; if (config.apps.some((app) => app.name === name || app.dir.replace(/\\/g, "/") === dir)) { throw new Error(`Workspace app '${name}' already exists.`); } 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); if (existsSync(appRoot)) throw new Error(`Refusing to overwrite existing directory: ${appRoot}`); const original = readFileSync(configPath, "utf8"); const next = insertWorkspaceApp(original, { name, dir, domains: [domain], port }); scaffoldApp(appRoot, name); try { writeFileSync(configPath, next, "utf8"); } catch (error) { rmSync(appRoot, { recursive: true, force: true }); throw error; } console.log(`✓ Added app ${name}`); console.log(` directory: ${dir}`); console.log(` domain: http://${domain}:3000`); 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) { console.error("Usage: wrnexus workspace "); process.exit(1); } const root = resolve(process.cwd(), name); if (existsSync(root)) { console.error(`Refusing to overwrite existing directory: ${root}`); process.exit(1); } for (const [rel, contents] of Object.entries(workspaceFiles(name))) { const target = join(root, rel); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, contents, "utf8"); } // Two starter apps under apps/. scaffoldApp(join(root, "apps", "web"), "web"); scaffoldApp(join(root, "apps", "admin"), "admin"); console.log(`✓ Created workspace ${name}`); console.log(`\nNext steps:`); console.log(` cd ${name}`); console.log(` bun install`); console.log(` bun run dev # wrnexus gateway (web + admin, routed by domain)`); } /** Load `wrnexus.workspace.ts` from a directory. */ export async function loadWorkspaceConfig(root: string): Promise { const path = workspaceConfigPath(root); if (path) { const mod = (await import(pathToFileURL(path).href)) as { default?: WorkspaceConfig }; if (!mod.default?.apps?.length) { throw new Error(`${path.split(/[\\/]/).at(-1)} must default-export { apps: [...] }`); } return mod.default; } throw new Error("No wrnexus.workspace.ts found. Run `wrnexus workspace ` to scaffold one."); } /** Run the multi-app gateway from `wrnexus.workspace.ts`. */ export async function runGateway(root: string, args: string[]): Promise { const config = await loadWorkspaceConfig(resolve(root)); 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 { startGateway } = await import("@wrnexus/dev-server"); await startGateway({ port, hostname, 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(); 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, }; }