Files
WRNexusJS/packages/cli/src/workspace.ts
T
2026-07-14 17:34:51 +05:30

551 lines
16 KiB
TypeScript

/**
* Monorepo support:
* - `wrnexus workspace <name>` scaffolds a multi-app workspace (apps/* + shared packages/*)
* - `wrnexus workspace add <name> --domain=<host>` 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 <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;
auth?: GatewayAuth;
}
export interface WorkspaceConfig {
defaultEnvironment?: WorkspaceEnvironment;
environments?: Record<WorkspaceEnvironment, WorkspaceEnvironmentConfig>;
apps: WorkspaceApp[];
security?: GatewaySecurity;
}
export const workspaceFiles = (name: string): Record<string, string> => ({
"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<void> {
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 <name>");
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<WorkspaceConfig> {
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 <name>` to scaffold one.");
}
/** 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((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<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,
};
}