551 lines
16 KiB
TypeScript
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,
|
|
};
|
|
}
|