/** * 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, readdirSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { scaffoldApp, scaffoldFrameworkRange } 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; hostname?: string; runtime?: "development" | "production"; hmr?: boolean; build?: boolean; migrate?: boolean; profile?: string; } 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?: Partial>; 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", "staging": "wrnexus staging", "production": "wrnexus production", "typecheck": "tsc --noEmit && bun run --filter './apps/*' typecheck", "test": "bun run --filter './apps/*' test", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier . --write", "format:check": "prettier . --check", "doctor": "bun run --filter './apps/*' doctor", "check": "bun run typecheck && bun run lint && bun run test && bun run format:check" }, "devDependencies": { "@wrnexus/cli": "${frameworkVersion}", "@eslint/js": "^10.0.1", "@types/bun": "^1.3.14", "eslint": "^10.8.1", "prettier": "^3.9.6", "typescript": "^6.0.3", "typescript-eslint": "^8.67.0" } } `, "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 = { defaultEnvironment: "development", environments: { development: { protocol: "http", rootDomain: "localhost", port: 3000, runtime: "development", hmr: true, build: false, migrate: false }, staging: { protocol: "https", rootDomain: "staging.example.com", port: 443, runtime: "production", hmr: false, build: true, migrate: true }, production: { protocol: "https", rootDomain: "example.com", port: 443, runtime: "production", hmr: false, build: true, migrate: true }, }, // 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/ **/.wrnexus/ coverage/ .env .env.* !.env.example !.env.*.example *.log *.db *.db-shm *.db-wal *.sqlite *.sqlite3 uploads/ mobile/android/ mobile/ios/ mobile/.expo/ .idea/ .vscode/* !.vscode/settings.json !.vscode/extensions.json *.tsbuildinfo .eslintcache `, ".env.example": `REDIS_URL=redis://localhost:6379 AUTH_SECRET=replace-with-at-least-32-random-characters `, ".prettierrc.json": `{ "printWidth": 100, "tabWidth": 2, "useTabs": false, "semi": true, "singleQuote": false, "trailingComma": "all", "endOfLine": "lf" } `, ".prettierignore": `node_modules/ dist/ .wrnexus/ **/.wrnexus/ *.log **/CLAUDE.md `, ".editorconfig": `root = true [*] charset = utf-8 end_of_line = lf indent_style = space indent_size = 2 insert_final_newline = true trim_trailing_whitespace = true `, "eslint.config.js": `import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import js from "@eslint/js"; import tseslint from "typescript-eslint"; const tsconfigRootDir = dirname(fileURLToPath(import.meta.url)); export default tseslint.config( { ignores: ["node_modules/**", "dist/**", "**/dist/**", ".wrnexus/**", "**/.wrnexus/**"] }, { languageOptions: { parserOptions: { tsconfigRootDir } } }, js.configs.recommended, ...tseslint.configs.recommended, { files: ["**/*.{ts,tsx}"], rules: { "no-undef": "off", "no-console": "off", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }], }, }, ); `, "tsconfig.json": `{ "compilerOptions": { "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", "lib": ["ESNext", "DOM"], "types": ["bun"], "strict": true, "skipLibCheck": true, "noEmit": true, "allowImportingTsExtensions": true }, "include": ["wrnexus.workspace.ts", "packages/**/*.ts"], "exclude": ["node_modules", "dist", "apps"] } `, ".vscode/settings.json": `{ "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, "prettier.requireConfig": true, "[wrn]": { "editor.defaultFormatter": "wrnexus.wrnexus", "editor.formatOnSave": true } } `, ".vscode/extensions.json": `{ "recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] } `, "packages/shared/package.json": `{ "name": "@app/shared", "version": "0.0.0", "private": true, "type": "module", "main": "src/index.ts", "exports": { ".": "./src/index.ts" }, "scripts": { "typecheck": "tsc --noEmit", "test": "bun test" }, "dependencies": { "@wrnexus/pubsub": "${scaffoldFrameworkRange}" } } `, "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 || (args.includes("--prod") ? "production" : config.defaultEnvironment || "development"); const resolved = resolveWorkspaceConfig(config, environment); const environmentConfig = resolved.config; const runtime = environmentConfig.runtime ?? (environment === "development" ? "development" : "production"); const port = portArg ? Number(portArg.split("=")[1]) : (environmentConfig.port ?? (runtime === "development" ? 3000 : 80)); const hostname = hostArg?.split("=")[1] ?? environmentConfig.hostname; // The published CLI bundle carries the matching gateway/cache lifecycle. const { startGateway } = await import("@wrnexus/dev-server"); await startGateway({ port, hostname, mode: args.includes("--prod") || process.env.NODE_ENV === "production" ? "production" : runtime, hmr: runtime === "development" ? (environmentConfig.hmr ?? true) : false, 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, })), }); } /** Return default/named database targets that contain SQL migrations. */ export function workspaceMigrationTargets(appRoot: string): Array { const dbRoot = join(resolve(appRoot), "app", "db"); if (!existsSync(dbRoot)) return []; const targets: Array = []; const hasSql = (dir: string) => existsSync(dir) && readdirSync(dir, { withFileTypes: true }).some( (entry) => entry.isFile() && entry.name.endsWith(".sql"), ); if (hasSql(join(dbRoot, "migrations"))) targets.push(null); for (const entry of readdirSync(dbRoot, { withFileTypes: true })) { if ( entry.isDirectory() && entry.name !== "migrations" && hasSql(join(dbRoot, entry.name, "migrations")) ) { targets.push(entry.name); } } return targets; } /** Build, migrate, then serve every registered workspace app in production mode. */ export async function runProduction(root: string, args: string[]): Promise { const workspaceRoot = resolve(root); const config = await loadWorkspaceConfig(workspaceRoot); const prepareOnly = args.includes("--prepare-only"); const environmentArg = args.find((arg) => arg.startsWith("--environment=")); const profileArg = args.find((arg) => arg.startsWith("--profile=")); const environment = environmentArg?.split("=")[1] || profileArg?.split("=")[1] || "production"; const environmentConfig = config.environments?.[environment] ?? {}; const profile = environmentConfig.profile ?? environment; const shouldBuild = !args.includes("--no-build") && (args.includes("--build") || environmentConfig.build !== false); const shouldMigrate = !args.includes("--no-migrate") && (args.includes("--migrate") || environmentConfig.migrate !== false); process.env.NODE_ENV = "production"; process.env.WRNEXUS_ENV = environment; process.env.WRNEXUS_PROFILE = profile; console.log(`\n ⚡ Preparing WRNexus workspace (${environment})\n`); if (shouldBuild) { const { runBuild } = await import("./build.ts"); for (const app of config.apps) { const appRoot = resolve(workspaceRoot, app.dir); if (!existsSync(join(appRoot, "package.json"))) { throw new Error(`Workspace app '${app.name}' has no package.json at ${appRoot}.`); } console.log(`\n ▸ Building ${app.name}`); await runBuild(appRoot); } } else { console.log(" = builds skipped (--no-build)"); } if (shouldMigrate) { const { runDbCommand } = await import("./db.ts"); for (const app of config.apps) { const appRoot = resolve(workspaceRoot, app.dir); const targets = workspaceMigrationTargets(appRoot); if (!targets.length) { console.log(` = ${app.name}: no SQL migrations`); continue; } for (const target of targets) { console.log(`\n ▸ Migrating ${app.name}${target ? ` (${target})` : ""}`); await runDbCommand(appRoot, "migrate", target ? [`--db=${target}`] : []); } } } else { console.log(" = migrations skipped (--no-migrate)"); } console.log("\n ✓ Workspace builds and migrations are ready.\n"); if (prepareOnly) return; const gatewayArgs = args.filter( (arg) => arg !== "--prepare-only" && arg !== "--no-build" && arg !== "--no-migrate", ); if (!gatewayArgs.some((arg) => arg.startsWith("--environment="))) { gatewayArgs.push(`--environment=${environment}`); } if (!gatewayArgs.includes("--prod")) gatewayArgs.push("--prod"); if ( !gatewayArgs.some((arg) => arg.startsWith("--host=")) && environmentConfig.hostname === undefined ) { gatewayArgs.push("--host=0.0.0.0"); } await runGateway(workspaceRoot, gatewayArgs); } 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; config: WorkspaceEnvironmentConfig; 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, config: environmentConfig, apps, security: config.security, }; }