Files
WRNexusJS/packages/cli/src/workspace.ts
T
ClintchizandClaude Opus 5 17aa3b98eb feat(islands): wire islands end to end
The island pieces existed but nothing connected .wrn compilation to island
emission. Now:

- codegen emits a data-wrn-island placeholder for component tags bound to
  .tsx imports, keeping .wrn components on the normal mount path
- the dev pipeline and static build resolve island imports, thread the
  names into codegen, and build the bundles
- collectScripts adds /__wrnexus/islands.js only when island markup is
  present, so island-free pages still ship nothing
- island routes classify as static-interactive via hasIslands

Three bugs found by driving a real page in the browser:

1. The mount runtime was never built anywhere, so the bootstrap 404'd and
   no island mounted.
2. Building the runtime separately from the islands gave each its own copy
   of React: "Cannot read properties of null (reading 'useState')". The
   runtime is now an entrypoint of the same build so React stays in one
   shared chunk. The existing single-React test only compared bundles
   within one build and could not see across build boundaries.
3. Island props arrived as attribute strings, so start={3} was "3" and
   incrementing produced "31" then "311". Props now follow JSX semantics:
   {…} parses as JSON, quoted values stay strings, and a runtime
   expression is a WRN-ISLAND-PROPS build error rather than a silent
   wrong value.

island-codegen.ts no longer imports @wrnexus/core. Compiler modules are
bundled into the Node-only VS Code extension, which contains no other
packages, so a runtime import of core broke the editor compiler; the two
helpers are implemented locally and the core dependency is dropped again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:16:03 +05:30

783 lines
24 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, 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 <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?: Partial<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",
"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<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 ||
(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<string | null> {
const dbRoot = join(resolve(appRoot), "app", "db");
if (!existsSync(dbRoot)) return [];
const targets: Array<string | null> = [];
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<void> {
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<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,
config: environmentConfig,
apps,
security: config.security,
};
}