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>
783 lines
24 KiB
TypeScript
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,
|
|
};
|
|
}
|