feat: add helpers and improve workspace auth flows
This commit is contained in:
@@ -167,6 +167,21 @@ export const POST = async (ctx) => {
|
||||
\`req: Request\`, \`url: URL\`, \`params: Record<string,string>\` (dynamic route params, e.g. \`/users/[id]\` → \`ctx.params.id\`),
|
||||
\`lang: string\`, \`t(key, params?)\` (i18n), \`cookies\` (get/set), \`session\` (get/set). Auth: \`getUser(ctx)\` after \`sessionAuth\`/\`logIn\`.
|
||||
|
||||
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
|
||||
\`@wrnexus/helpers\` instead of constructing it from untrusted headers:
|
||||
|
||||
\`\`\`ts
|
||||
import { redirectToLogin } from "@wrnexus/helpers";
|
||||
|
||||
return redirectToLogin(ctx, "/login", {
|
||||
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
The package also exports \`getOriginalRequestUrl\`, \`getOriginalRequestOrigin\`,
|
||||
\`getOriginalRequestPath\`, and \`getOriginalRequestMethod\`. Always pass \`allowedHosts\` when
|
||||
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
|
||||
|
||||
## Middleware & realtime
|
||||
|
||||
\`\`\`ts
|
||||
|
||||
@@ -84,6 +84,7 @@ Thumbs.db
|
||||
"dependencies": {
|
||||
"@wrnexus/ai": "${frameworkVersion}",
|
||||
"@wrnexus/core": "${frameworkVersion}",
|
||||
"@wrnexus/helpers": "${frameworkVersion}",
|
||||
"@wrnexus/styles": "${frameworkVersion}",
|
||||
"@wrnexus/validation": "${frameworkVersion}",
|
||||
"@wrnexus/db": "${frameworkVersion}"
|
||||
@@ -175,6 +176,7 @@ dist/
|
||||
.wrnexus/
|
||||
**/.wrnexus/
|
||||
*.log
|
||||
CLAUDE.md
|
||||
`,
|
||||
".editorconfig": `root = true
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ Usage:
|
||||
wrnexus build [app-dir] Build a production server bundle + assets
|
||||
wrnexus create <app-name> Scaffold a new app
|
||||
wrnexus workspace <name> Scaffold a monorepo (apps/* + shared packages/*)
|
||||
wrnexus workspace add <name> [--domain=name.localhost]
|
||||
Add an app to the current workspace
|
||||
wrnexus gateway [--port=3000] Serve every workspace app behind one port, routed by domain
|
||||
wrnexus generate <type> <name> Scaffold a page | component | api | schema
|
||||
wrnexus generate routes | docker | mobile
|
||||
@@ -92,8 +94,12 @@ async function main(): Promise<void> {
|
||||
createApp(rest[0] ?? "");
|
||||
break;
|
||||
case "workspace": {
|
||||
const { createWorkspace } = await import("./workspace.ts");
|
||||
createWorkspace(rest.find((a) => !a.startsWith("--")) ?? "");
|
||||
const { addWorkspaceApp, createWorkspace } = await import("./workspace.ts");
|
||||
if (rest[0] === "add") {
|
||||
await addWorkspaceApp(".", rest[1] ?? "", rest.slice(2));
|
||||
} else {
|
||||
createWorkspace(rest.find((a) => !a.startsWith("--")) ?? "");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "gateway": {
|
||||
|
||||
+137
-10
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -9,7 +10,7 @@
|
||||
* cross-process). `wrnexus.workspace.ts` maps each app to the domains it serves.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
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";
|
||||
@@ -151,12 +152,139 @@ apps are internal gateway targets, not public workspace URLs.
|
||||
## Add another app
|
||||
|
||||
\`\`\`bash
|
||||
wrnexus create apps/reports
|
||||
# then add it to wrnexus.workspace.ts with its domains
|
||||
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.`);
|
||||
}
|
||||
if (config.apps.some((app) => app.domains.includes(domain))) {
|
||||
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`.");
|
||||
}
|
||||
|
||||
/** Scaffold a monorepo workspace with two starter apps + a shared package. */
|
||||
export function createWorkspace(name: string): void {
|
||||
if (!name) {
|
||||
@@ -187,14 +315,13 @@ export function createWorkspace(name: string): void {
|
||||
|
||||
/** Load `wrnexus.workspace.ts` from a directory. */
|
||||
export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig> {
|
||||
for (const file of ["wrnexus.workspace.ts", "wrnexus.workspace.js", "wrnexus.workspace.mjs"]) {
|
||||
const path = join(root, file);
|
||||
if (existsSync(path)) {
|
||||
const mod = (await import(pathToFileURL(path).href)) as { default?: WorkspaceConfig };
|
||||
if (!mod.default?.apps?.length)
|
||||
throw new Error(`${file} must default-export { apps: [...] }`);
|
||||
return mod.default;
|
||||
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.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user