otavia 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bun.lock +589 -0
- package/package.json +35 -0
- package/src/cli.ts +153 -0
- package/src/commands/__tests__/aws-auth.test.ts +32 -0
- package/src/commands/__tests__/cell.test.ts +44 -0
- package/src/commands/__tests__/dev.test.ts +49 -0
- package/src/commands/__tests__/init.test.ts +47 -0
- package/src/commands/__tests__/setup.test.ts +263 -0
- package/src/commands/aws-auth.ts +32 -0
- package/src/commands/aws.ts +59 -0
- package/src/commands/cell.ts +33 -0
- package/src/commands/clean.ts +32 -0
- package/src/commands/deploy.ts +508 -0
- package/src/commands/dev/__tests__/fixtures/gateway-cell/cell.yaml +8 -0
- package/src/commands/dev/__tests__/gateway-backend-routes.test.ts +13 -0
- package/src/commands/dev/__tests__/gateway-forward-url.test.ts +20 -0
- package/src/commands/dev/__tests__/gateway-sso-base-url.test.ts +93 -0
- package/src/commands/dev/__tests__/tunnel.test.ts +93 -0
- package/src/commands/dev/__tests__/vite-dev-proxy-rules.test.ts +220 -0
- package/src/commands/dev/__tests__/well-known.test.ts +88 -0
- package/src/commands/dev/forward-url.ts +7 -0
- package/src/commands/dev/gateway.ts +421 -0
- package/src/commands/dev/main-frontend-runtime/main-entry.ts +35 -0
- package/src/commands/dev/main-frontend-runtime/vite-config.ts +210 -0
- package/src/commands/dev/mount-selection.ts +9 -0
- package/src/commands/dev/tunnel.ts +176 -0
- package/src/commands/dev/vite-dev.ts +382 -0
- package/src/commands/dev/well-known.ts +76 -0
- package/src/commands/dev.ts +107 -0
- package/src/commands/init.ts +69 -0
- package/src/commands/lint.ts +49 -0
- package/src/commands/setup.ts +887 -0
- package/src/commands/test.ts +331 -0
- package/src/commands/typecheck.ts +36 -0
- package/src/config/__tests__/load-cell-yaml.test.ts +248 -0
- package/src/config/__tests__/load-otavia-yaml.test.ts +492 -0
- package/src/config/__tests__/ports.test.ts +48 -0
- package/src/config/__tests__/resolve-cell-dir.test.ts +60 -0
- package/src/config/__tests__/resolve-params.test.ts +137 -0
- package/src/config/__tests__/resource-names.test.ts +62 -0
- package/src/config/cell-yaml-schema.ts +115 -0
- package/src/config/load-cell-yaml.ts +87 -0
- package/src/config/load-otavia-yaml.ts +256 -0
- package/src/config/otavia-yaml-schema.ts +49 -0
- package/src/config/ports.ts +57 -0
- package/src/config/resolve-cell-dir.ts +55 -0
- package/src/config/resolve-params.ts +160 -0
- package/src/config/resource-names.ts +60 -0
- package/src/deploy/__tests__/template.test.ts +137 -0
- package/src/deploy/api-gateway.ts +96 -0
- package/src/deploy/cloudflare-dns.ts +261 -0
- package/src/deploy/cloudfront.ts +228 -0
- package/src/deploy/dynamodb.ts +68 -0
- package/src/deploy/lambda.ts +121 -0
- package/src/deploy/s3.ts +57 -0
- package/src/deploy/template.ts +264 -0
- package/src/deploy/types.ts +16 -0
- package/src/local/docker.ts +175 -0
- package/src/local/dynamodb-local.ts +124 -0
- package/src/local/minio-local.ts +44 -0
- package/src/utils/env.test.ts +74 -0
- package/src/utils/env.ts +79 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import concurrently from "concurrently";
|
|
5
|
+
import { parse as parseYaml } from "yaml";
|
|
6
|
+
|
|
7
|
+
type TunnelConfig = {
|
|
8
|
+
ingress?: Array<{ hostname?: string }>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type TunnelHandle = {
|
|
12
|
+
publicBaseUrl: string;
|
|
13
|
+
stop: () => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const TUNNEL_LOG_LEVELS = ["debug", "info", "warn", "error"] as const;
|
|
17
|
+
type TunnelLogLevel = (typeof TUNNEL_LOG_LEVELS)[number];
|
|
18
|
+
const DEFAULT_TUNNEL_LOG_LEVEL: TunnelLogLevel = "warn";
|
|
19
|
+
const TUNNEL_PROTOCOLS = ["auto", "quic", "http2"] as const;
|
|
20
|
+
type TunnelProtocol = (typeof TUNNEL_PROTOCOLS)[number];
|
|
21
|
+
const DEFAULT_TUNNEL_PROTOCOL: TunnelProtocol = "quic";
|
|
22
|
+
|
|
23
|
+
export function extractTunnelHostFromConfig(configContent: string): string | null {
|
|
24
|
+
const parsed = parseYaml(configContent) as TunnelConfig | null;
|
|
25
|
+
const ingress = parsed?.ingress;
|
|
26
|
+
if (!Array.isArray(ingress)) return null;
|
|
27
|
+
for (const rule of ingress) {
|
|
28
|
+
const host = rule?.hostname?.trim();
|
|
29
|
+
if (!host) continue;
|
|
30
|
+
if (host.startsWith("*.")) continue;
|
|
31
|
+
return host;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function normalizeTunnelPublicBaseUrl(hostOrUrl: string): string {
|
|
37
|
+
const trimmed = hostOrUrl.trim().replace(/\/$/, "");
|
|
38
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
|
39
|
+
return trimmed;
|
|
40
|
+
}
|
|
41
|
+
return `https://${trimmed}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function defaultTunnelConfigPath(rootDir: string): string {
|
|
45
|
+
const fromEnv = process.env.OTAVIA_TUNNEL_CONFIG?.trim();
|
|
46
|
+
if (fromEnv) return fromEnv;
|
|
47
|
+
const projectPath = resolve(rootDir, ".otavia", "tunnel", "config.yml");
|
|
48
|
+
if (existsSync(projectPath)) return projectPath;
|
|
49
|
+
const globalConfig = resolve(homedir(), ".config", "otavia", "config.yml");
|
|
50
|
+
if (existsSync(globalConfig)) return globalConfig;
|
|
51
|
+
const legacyGlobalConfig = resolve(homedir(), ".config", "otavia", "tunnel.yaml");
|
|
52
|
+
return legacyGlobalConfig;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolveTunnelLogLevel(level?: string): TunnelLogLevel {
|
|
56
|
+
const normalized = (level ?? process.env.OTAVIA_TUNNEL_LOG_LEVEL ?? DEFAULT_TUNNEL_LOG_LEVEL)
|
|
57
|
+
.trim()
|
|
58
|
+
.toLowerCase();
|
|
59
|
+
if (TUNNEL_LOG_LEVELS.includes(normalized as TunnelLogLevel)) {
|
|
60
|
+
return normalized as TunnelLogLevel;
|
|
61
|
+
}
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Invalid tunnel log level "${normalized}". Expected one of: ${TUNNEL_LOG_LEVELS.join(", ")}.`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function resolveTunnelProtocol(protocol?: string): TunnelProtocol {
|
|
68
|
+
const normalized = (protocol ?? process.env.OTAVIA_TUNNEL_PROTOCOL ?? DEFAULT_TUNNEL_PROTOCOL)
|
|
69
|
+
.trim()
|
|
70
|
+
.toLowerCase();
|
|
71
|
+
if (TUNNEL_PROTOCOLS.includes(normalized as TunnelProtocol)) {
|
|
72
|
+
return normalized as TunnelProtocol;
|
|
73
|
+
}
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Invalid tunnel protocol "${normalized}". Expected one of: ${TUNNEL_PROTOCOLS.join(", ")}.`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function buildCloudflaredTunnelCommand(
|
|
80
|
+
tunnelConfigPath: string,
|
|
81
|
+
tunnelLogLevel: TunnelLogLevel,
|
|
82
|
+
tunnelProtocol: TunnelProtocol
|
|
83
|
+
): string {
|
|
84
|
+
return `cloudflared tunnel --loglevel ${tunnelLogLevel} --protocol ${tunnelProtocol} --config ${JSON.stringify(tunnelConfigPath)} run`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function startTunnel(
|
|
88
|
+
rootDir: string,
|
|
89
|
+
options?: {
|
|
90
|
+
tunnelConfigPath?: string;
|
|
91
|
+
tunnelHost?: string;
|
|
92
|
+
tunnelLogLevel?: string;
|
|
93
|
+
tunnelProtocol?: string;
|
|
94
|
+
}
|
|
95
|
+
): Promise<TunnelHandle> {
|
|
96
|
+
const tunnelConfigPath = options?.tunnelConfigPath ?? defaultTunnelConfigPath(rootDir);
|
|
97
|
+
if (!existsSync(tunnelConfigPath)) {
|
|
98
|
+
console.log("[tunnel] No tunnel config found. Running auto-setup...");
|
|
99
|
+
// Set OTAVIA_TUNNEL_DEV_ROOT from otavia.yaml dns.zone if not already set
|
|
100
|
+
const { loadOtaviaYaml } = await import("../../config/load-otavia-yaml.js");
|
|
101
|
+
if (!process.env.OTAVIA_TUNNEL_DEV_ROOT) {
|
|
102
|
+
const otavia = loadOtaviaYaml(rootDir);
|
|
103
|
+
const zone = otavia.domain?.dns?.zone;
|
|
104
|
+
if (zone) {
|
|
105
|
+
process.env.OTAVIA_TUNNEL_DEV_ROOT = zone;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const { setupCommand } = await import("../setup.js");
|
|
109
|
+
await setupCommand(rootDir, { tunnel: true });
|
|
110
|
+
if (!existsSync(tunnelConfigPath)) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Tunnel config not found after auto-setup: ${tunnelConfigPath}. Run setup manually or pass --tunnel-config.`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const cloudflaredExit = await Bun.spawn(["cloudflared", "--version"], {
|
|
117
|
+
stdout: "pipe",
|
|
118
|
+
stderr: "pipe",
|
|
119
|
+
}).exited;
|
|
120
|
+
if (cloudflaredExit !== 0) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
"cloudflared not found. Install from https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/"
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const configText = await Bun.file(tunnelConfigPath).text();
|
|
127
|
+
const host =
|
|
128
|
+
options?.tunnelHost?.trim() ||
|
|
129
|
+
process.env.OTAVIA_TUNNEL_HOST?.trim() ||
|
|
130
|
+
extractTunnelHostFromConfig(configText);
|
|
131
|
+
if (!host) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Cannot find tunnel hostname in ${tunnelConfigPath}. Add ingress.hostname or pass --tunnel-host.`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const publicBaseUrl = normalizeTunnelPublicBaseUrl(host);
|
|
137
|
+
const tunnelLogLevel = resolveTunnelLogLevel(options?.tunnelLogLevel);
|
|
138
|
+
const tunnelProtocol = resolveTunnelProtocol(options?.tunnelProtocol);
|
|
139
|
+
const tunnelCommand = buildCloudflaredTunnelCommand(tunnelConfigPath, tunnelLogLevel, tunnelProtocol);
|
|
140
|
+
const { commands, result } = concurrently(
|
|
141
|
+
[
|
|
142
|
+
{
|
|
143
|
+
command: tunnelCommand,
|
|
144
|
+
name: "tunnel",
|
|
145
|
+
cwd: rootDir,
|
|
146
|
+
env: { ...process.env },
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
{
|
|
150
|
+
prefix: "[{name}]",
|
|
151
|
+
prefixColors: ["cyan"],
|
|
152
|
+
raw: false,
|
|
153
|
+
handleInput: false,
|
|
154
|
+
killOthersOn: ["failure"],
|
|
155
|
+
}
|
|
156
|
+
);
|
|
157
|
+
let stopped = false;
|
|
158
|
+
result.catch((events) => {
|
|
159
|
+
if (stopped) return;
|
|
160
|
+
const tunnelEvent = Array.isArray(events) ? events.find((event) => event.command.name === "tunnel") : null;
|
|
161
|
+
const exitCode = tunnelEvent?.exitCode;
|
|
162
|
+
console.error(`[tunnel] cloudflared exited with code ${exitCode ?? "unknown"}`);
|
|
163
|
+
});
|
|
164
|
+
result.then(() => {
|
|
165
|
+
if (stopped) return;
|
|
166
|
+
console.error("[tunnel] cloudflared exited.");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
publicBaseUrl,
|
|
171
|
+
stop: () => {
|
|
172
|
+
stopped = true;
|
|
173
|
+
commands[0]?.kill("SIGTERM");
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { relative, resolve } from "node:path";
|
|
3
|
+
import { loadOtaviaYaml } from "../../config/load-otavia-yaml.js";
|
|
4
|
+
import { loadCellConfig } from "../../config/load-cell-yaml.js";
|
|
5
|
+
import type { CellConfig } from "../../config/cell-yaml-schema.js";
|
|
6
|
+
import { resolveCellDir } from "../../config/resolve-cell-dir.js";
|
|
7
|
+
import { resolveRootRedirectMount } from "./mount-selection.js";
|
|
8
|
+
|
|
9
|
+
export interface ViteDevHandle {
|
|
10
|
+
stop: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type RouteMatch = "prefix" | "exact";
|
|
14
|
+
|
|
15
|
+
export type RouteRule = {
|
|
16
|
+
path: string;
|
|
17
|
+
match: RouteMatch;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ProxyRule = {
|
|
21
|
+
mount: string;
|
|
22
|
+
path: string;
|
|
23
|
+
match: RouteMatch;
|
|
24
|
+
target: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type MainDevGeneratedConfig = {
|
|
28
|
+
firstMount: string;
|
|
29
|
+
mounts: string[];
|
|
30
|
+
routeRules: RouteRule[];
|
|
31
|
+
proxyRules: ProxyRule[];
|
|
32
|
+
frontendModuleProxyRules: Array<{
|
|
33
|
+
path: string;
|
|
34
|
+
sourcePath: string;
|
|
35
|
+
}>;
|
|
36
|
+
frontendRouteRules: Array<{
|
|
37
|
+
mount: string;
|
|
38
|
+
path: string;
|
|
39
|
+
match: RouteMatch;
|
|
40
|
+
entryName: string;
|
|
41
|
+
entryType: "html" | "module";
|
|
42
|
+
}>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const GLOBAL_WELL_KNOWN_RULE: RouteRule = { path: "/.well-known", match: "prefix" };
|
|
46
|
+
const GLOBAL_PROXY_MOUNT = "__global__";
|
|
47
|
+
|
|
48
|
+
const MAIN_FRONTEND_INDEX_HTML = `<!DOCTYPE html>
|
|
49
|
+
<html lang="en">
|
|
50
|
+
<head>
|
|
51
|
+
<meta charset="UTF-8" />
|
|
52
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
53
|
+
<title>Otavia Main</title>
|
|
54
|
+
</head>
|
|
55
|
+
<body>
|
|
56
|
+
<div id="root"></div>
|
|
57
|
+
<script type="module" src="/src/main.ts"></script>
|
|
58
|
+
</body>
|
|
59
|
+
</html>
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
const MAIN_FRONTEND_ENTRY_TS = `import { rootRedirectMount, mountLoaders, mounts } from "./generated/mount-loaders";
|
|
63
|
+
import { bootMainFrontend } from "otavia/dev/main-frontend-runtime/main-entry";
|
|
64
|
+
|
|
65
|
+
void bootMainFrontend(rootRedirectMount, mounts, mountLoaders);
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
const MAIN_FRONTEND_VITE_CONFIG_TS = `import { createMainFrontendViteConfig } from "otavia/dev/main-frontend-runtime/vite-config";
|
|
69
|
+
|
|
70
|
+
const backendPort = process.env.GATEWAY_BACKEND_PORT;
|
|
71
|
+
const vitePort = Number.parseInt(process.env.VITE_PORT ?? "", 10);
|
|
72
|
+
const generatedConfigPath = new URL("./src/generated/main-dev-config.json", import.meta.url);
|
|
73
|
+
const packageRoot = process.env.OTAVIA_MAIN_ROOT ?? process.cwd();
|
|
74
|
+
|
|
75
|
+
if (!backendPort) {
|
|
76
|
+
throw new Error("Missing GATEWAY_BACKEND_PORT");
|
|
77
|
+
}
|
|
78
|
+
if (!Number.isFinite(vitePort)) {
|
|
79
|
+
throw new Error("Missing VITE_PORT");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default createMainFrontendViteConfig({
|
|
83
|
+
generatedConfigPath,
|
|
84
|
+
packageRoot,
|
|
85
|
+
backendPort,
|
|
86
|
+
vitePort,
|
|
87
|
+
});
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
function writeMainFrontendShell(frontendRoot: string): void {
|
|
91
|
+
const srcDir = resolve(frontendRoot, "src");
|
|
92
|
+
const generatedDir = resolve(srcDir, "generated");
|
|
93
|
+
mkdirSync(generatedDir, { recursive: true });
|
|
94
|
+
writeFileSync(resolve(frontendRoot, "index.html"), MAIN_FRONTEND_INDEX_HTML, "utf-8");
|
|
95
|
+
writeFileSync(resolve(frontendRoot, "vite.config.ts"), MAIN_FRONTEND_VITE_CONFIG_TS, "utf-8");
|
|
96
|
+
writeFileSync(resolve(srcDir, "main.ts"), MAIN_FRONTEND_ENTRY_TS, "utf-8");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeRoutePath(path: string): string {
|
|
100
|
+
if (!path.startsWith("/")) {
|
|
101
|
+
throw new Error(`Invalid backend route "${path}": route must start with "/"`);
|
|
102
|
+
}
|
|
103
|
+
const trimmed = path.replace(/\/+$/, "");
|
|
104
|
+
return trimmed || "/";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function deriveRouteRulesFromCellConfig(config: CellConfig): RouteRule[] {
|
|
108
|
+
const seen = new Set<string>();
|
|
109
|
+
const rules: RouteRule[] = [];
|
|
110
|
+
const entries = config.backend?.entries ? Object.values(config.backend.entries) : [];
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
for (const route of entry.routes ?? []) {
|
|
113
|
+
const isPrefix = route.endsWith("/*");
|
|
114
|
+
const rawPath = isPrefix ? route.slice(0, -2) : route;
|
|
115
|
+
const path = normalizeRoutePath(rawPath);
|
|
116
|
+
const match: RouteMatch = isPrefix ? "prefix" : "exact";
|
|
117
|
+
const key = `${path}|${match}`;
|
|
118
|
+
if (seen.has(key)) continue;
|
|
119
|
+
seen.add(key);
|
|
120
|
+
rules.push({ path, match });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return rules;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function buildMainDevGeneratedConfig(
|
|
127
|
+
cells: Array<{
|
|
128
|
+
mount: string;
|
|
129
|
+
routeRules: RouteRule[];
|
|
130
|
+
moduleProxySpecs: FrontendModuleProxySpec[];
|
|
131
|
+
frontendRouteRules: Array<{
|
|
132
|
+
mount: string;
|
|
133
|
+
path: string;
|
|
134
|
+
match: RouteMatch;
|
|
135
|
+
entryName: string;
|
|
136
|
+
entryType: "html" | "module";
|
|
137
|
+
}>;
|
|
138
|
+
}>,
|
|
139
|
+
backendPort: number,
|
|
140
|
+
sourcePathBaseDir?: string
|
|
141
|
+
): MainDevGeneratedConfig {
|
|
142
|
+
const mounts = cells.map((c) => c.mount);
|
|
143
|
+
const firstMount = mounts[0] ?? "";
|
|
144
|
+
const routeRulesMap = new Map<string, RouteRule>();
|
|
145
|
+
const proxyRules: ProxyRule[] = [];
|
|
146
|
+
const frontendModuleProxyRules: MainDevGeneratedConfig["frontendModuleProxyRules"] = [];
|
|
147
|
+
const frontendRouteRules: MainDevGeneratedConfig["frontendRouteRules"] = [];
|
|
148
|
+
const target = `http://localhost:${backendPort}`;
|
|
149
|
+
|
|
150
|
+
for (const cell of cells) {
|
|
151
|
+
frontendModuleProxyRules.push(
|
|
152
|
+
...cell.moduleProxySpecs.map((spec) => ({
|
|
153
|
+
path: spec.routePath,
|
|
154
|
+
sourcePath: sourcePathBaseDir
|
|
155
|
+
? relative(sourcePathBaseDir, spec.sourcePath).replace(/\\/g, "/")
|
|
156
|
+
: spec.sourcePath,
|
|
157
|
+
}))
|
|
158
|
+
);
|
|
159
|
+
frontendRouteRules.push(...cell.frontendRouteRules);
|
|
160
|
+
for (const rule of cell.routeRules) {
|
|
161
|
+
const rrKey = `${rule.path}|${rule.match}`;
|
|
162
|
+
if (!routeRulesMap.has(rrKey)) routeRulesMap.set(rrKey, rule);
|
|
163
|
+
const mountedPath = rule.path === "/" ? `/${cell.mount}` : `/${cell.mount}${rule.path}`;
|
|
164
|
+
proxyRules.push({
|
|
165
|
+
mount: cell.mount,
|
|
166
|
+
path: mountedPath,
|
|
167
|
+
match: rule.match,
|
|
168
|
+
target,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const globalRuleKey = `${GLOBAL_WELL_KNOWN_RULE.path}|${GLOBAL_WELL_KNOWN_RULE.match}`;
|
|
174
|
+
if (!routeRulesMap.has(globalRuleKey)) {
|
|
175
|
+
routeRulesMap.set(globalRuleKey, GLOBAL_WELL_KNOWN_RULE);
|
|
176
|
+
}
|
|
177
|
+
proxyRules.push({
|
|
178
|
+
mount: GLOBAL_PROXY_MOUNT,
|
|
179
|
+
path: GLOBAL_WELL_KNOWN_RULE.path,
|
|
180
|
+
match: GLOBAL_WELL_KNOWN_RULE.match,
|
|
181
|
+
target,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
proxyRules.sort((a, b) => {
|
|
185
|
+
if (a.path === b.path) {
|
|
186
|
+
if (a.match === b.match) return a.mount.localeCompare(b.mount);
|
|
187
|
+
return a.match === "exact" ? -1 : 1;
|
|
188
|
+
}
|
|
189
|
+
return b.path.length - a.path.length;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
firstMount,
|
|
194
|
+
mounts,
|
|
195
|
+
routeRules: Array.from(routeRulesMap.values()),
|
|
196
|
+
proxyRules,
|
|
197
|
+
frontendModuleProxyRules,
|
|
198
|
+
frontendRouteRules,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizeFrontendRoutePath(path: string): string {
|
|
203
|
+
if (path === "") return "/";
|
|
204
|
+
if (!path.startsWith("/")) {
|
|
205
|
+
throw new Error(`Invalid frontend route "${path}": route must start with "/"`);
|
|
206
|
+
}
|
|
207
|
+
const trimmed = path.replace(/\/+$/, "");
|
|
208
|
+
return trimmed || "/";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function toMountedPath(mount: string, routePath: string): string {
|
|
212
|
+
const normalizedRoute = normalizeFrontendRoutePath(routePath);
|
|
213
|
+
return normalizedRoute === "/" ? `/${mount}` : `/${mount}${normalizedRoute}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isHtmlEntry(entry: string): boolean {
|
|
217
|
+
return entry.toLowerCase().endsWith(".html");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function deriveFrontendRouteRulesFromCellConfig(
|
|
221
|
+
mount: string,
|
|
222
|
+
config: CellConfig
|
|
223
|
+
): MainDevGeneratedConfig["frontendRouteRules"] {
|
|
224
|
+
const entries = config.frontend?.entries ? Object.entries(config.frontend.entries) : [];
|
|
225
|
+
const rules: MainDevGeneratedConfig["frontendRouteRules"] = [];
|
|
226
|
+
for (const [entryName, entry] of entries) {
|
|
227
|
+
const entryType: "html" | "module" = isHtmlEntry(entry.entry) ? "html" : "module";
|
|
228
|
+
for (const route of entry.routes ?? []) {
|
|
229
|
+
const isPrefix = route.endsWith("/*");
|
|
230
|
+
const rawPath = isPrefix ? route.slice(0, -2) : route;
|
|
231
|
+
const path = toMountedPath(mount, rawPath);
|
|
232
|
+
rules.push({
|
|
233
|
+
mount,
|
|
234
|
+
path,
|
|
235
|
+
match: isPrefix ? "prefix" : "exact",
|
|
236
|
+
entryName,
|
|
237
|
+
entryType,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return rules;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
type FrontendModuleProxySpec = {
|
|
245
|
+
mount: string;
|
|
246
|
+
routePath: string;
|
|
247
|
+
sourcePath: string;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
export function deriveFrontendModuleProxySpecs(
|
|
251
|
+
mount: string,
|
|
252
|
+
cellDir: string,
|
|
253
|
+
config: CellConfig
|
|
254
|
+
): FrontendModuleProxySpec[] {
|
|
255
|
+
if (!config.frontend) return [];
|
|
256
|
+
const specs: FrontendModuleProxySpec[] = [];
|
|
257
|
+
for (const entry of Object.values(config.frontend.entries)) {
|
|
258
|
+
if (isHtmlEntry(entry.entry)) continue;
|
|
259
|
+
const sourcePath = resolve(cellDir, config.frontend.dir, entry.entry).replace(/\\/g, "/");
|
|
260
|
+
for (const route of entry.routes ?? []) {
|
|
261
|
+
if (route.endsWith("/*")) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`Invalid module frontend route "${route}" for mount "${mount}": wildcard routes are only supported for HTML entries`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
specs.push({
|
|
267
|
+
mount,
|
|
268
|
+
routePath: toMountedPath(mount, route),
|
|
269
|
+
sourcePath,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return specs;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Start main frontend Vite dev server (single root MPA shell):
|
|
278
|
+
* - root is apps/main/.otavia/dev/main-frontend
|
|
279
|
+
* - dynamically loads each cell frontend via package exports ("@pkg/frontend")
|
|
280
|
+
* - proxies API/OAuth requests to backend gateway with mount-aware rewrite
|
|
281
|
+
* If no cell has frontend, returns a no-op stop.
|
|
282
|
+
*/
|
|
283
|
+
export async function startViteDev(
|
|
284
|
+
rootDir: string,
|
|
285
|
+
backendPort: number,
|
|
286
|
+
vitePort: number,
|
|
287
|
+
publicBaseUrl?: string
|
|
288
|
+
): Promise<ViteDevHandle> {
|
|
289
|
+
const root = resolve(rootDir);
|
|
290
|
+
const otavia = loadOtaviaYaml(root);
|
|
291
|
+
const cellsWithFrontend: {
|
|
292
|
+
mount: string;
|
|
293
|
+
packageName: string;
|
|
294
|
+
routeRules: RouteRule[];
|
|
295
|
+
frontendRouteRules: MainDevGeneratedConfig["frontendRouteRules"];
|
|
296
|
+
moduleProxySpecs: FrontendModuleProxySpec[];
|
|
297
|
+
}[] = [];
|
|
298
|
+
|
|
299
|
+
for (const entry of otavia.cellsList) {
|
|
300
|
+
const cellDir = resolveCellDir(root, entry.package);
|
|
301
|
+
const cellYamlPath = resolve(cellDir, "cell.yaml");
|
|
302
|
+
if (!existsSync(cellYamlPath)) continue;
|
|
303
|
+
const config = loadCellConfig(cellDir);
|
|
304
|
+
if (!config.frontend?.dir) continue;
|
|
305
|
+
const routeRules = deriveRouteRulesFromCellConfig(config);
|
|
306
|
+
const frontendRouteRules = deriveFrontendRouteRulesFromCellConfig(entry.mount, config);
|
|
307
|
+
const moduleProxySpecs = deriveFrontendModuleProxySpecs(entry.mount, cellDir, config);
|
|
308
|
+
cellsWithFrontend.push({
|
|
309
|
+
mount: entry.mount,
|
|
310
|
+
packageName: entry.package,
|
|
311
|
+
routeRules,
|
|
312
|
+
frontendRouteRules,
|
|
313
|
+
moduleProxySpecs,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (cellsWithFrontend.length === 0) {
|
|
318
|
+
console.log("[vite] No cells with frontend, skipping Vite dev server");
|
|
319
|
+
return { stop: () => {} };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const frontendRoot = resolve(root, ".otavia", "dev", "main-frontend");
|
|
323
|
+
writeMainFrontendShell(frontendRoot);
|
|
324
|
+
const srcDir = resolve(frontendRoot, "src");
|
|
325
|
+
const generatedDir = resolve(srcDir, "generated");
|
|
326
|
+
mkdirSync(generatedDir, { recursive: true });
|
|
327
|
+
|
|
328
|
+
const generatedLoadersPath = resolve(generatedDir, "mount-loaders.ts");
|
|
329
|
+
const generatedDevConfigPath = resolve(generatedDir, "main-dev-config.json");
|
|
330
|
+
const firstMount = cellsWithFrontend[0].mount;
|
|
331
|
+
const rootRedirectMount = resolveRootRedirectMount(
|
|
332
|
+
cellsWithFrontend.map((c) => c.mount),
|
|
333
|
+
otavia.defaultCell
|
|
334
|
+
);
|
|
335
|
+
const loadersSource = `// Auto-generated by otavia dev. Do not edit.
|
|
336
|
+
export const firstMount = ${JSON.stringify(firstMount)};
|
|
337
|
+
export const rootRedirectMount = ${JSON.stringify(rootRedirectMount)};
|
|
338
|
+
export const mounts = ${JSON.stringify(cellsWithFrontend.map((c) => c.mount))};
|
|
339
|
+
export const mountLoaders = {
|
|
340
|
+
${cellsWithFrontend
|
|
341
|
+
.map((c) => ` ${JSON.stringify(c.mount)}: () => import(${JSON.stringify(`${c.packageName}/frontend`)}),`)
|
|
342
|
+
.join("\n")}
|
|
343
|
+
} as Record<string, () => Promise<unknown>>;
|
|
344
|
+
`;
|
|
345
|
+
writeFileSync(generatedLoadersPath, loadersSource, "utf-8");
|
|
346
|
+
const generatedDevConfig = buildMainDevGeneratedConfig(cellsWithFrontend, backendPort, root);
|
|
347
|
+
writeFileSync(generatedDevConfigPath, JSON.stringify(generatedDevConfig, null, 2), "utf-8");
|
|
348
|
+
|
|
349
|
+
const env = {
|
|
350
|
+
...process.env,
|
|
351
|
+
OTAVIA_MOUNTS: JSON.stringify(cellsWithFrontend.map((c) => c.mount)),
|
|
352
|
+
OTAVIA_FIRST_MOUNT: firstMount,
|
|
353
|
+
VITE_PORT: String(vitePort),
|
|
354
|
+
GATEWAY_BACKEND_PORT: String(backendPort),
|
|
355
|
+
OTAVIA_MAIN_ROOT: root,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const configPath = resolve(frontendRoot, "vite.config.ts");
|
|
359
|
+
const child = Bun.spawn(["bun", "x", "vite", "--config", configPath], {
|
|
360
|
+
cwd: frontendRoot,
|
|
361
|
+
env,
|
|
362
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const stop = () => {
|
|
366
|
+
child.kill();
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
child.exited.then((code) => {
|
|
370
|
+
if (code !== 0 && code !== null) {
|
|
371
|
+
console.error(`[vite] Process exited with code ${code}`);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const base = publicBaseUrl?.replace(/\/$/, "") ?? `http://localhost:${vitePort}`;
|
|
376
|
+
console.log(
|
|
377
|
+
`[vite] Main frontend dev server starting at ${base} (mounts: ${cellsWithFrontend
|
|
378
|
+
.map((c) => c.mount)
|
|
379
|
+
.join(", ")})`
|
|
380
|
+
);
|
|
381
|
+
return { stop };
|
|
382
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import type { CellConfig } from "../../config/cell-yaml-schema.js";
|
|
3
|
+
|
|
4
|
+
const AUTHORIZATION_SERVER_WELL_KNOWN_PREFIX = "/.well-known/oauth-authorization-server";
|
|
5
|
+
const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
|
|
6
|
+
|
|
7
|
+
type OAuthEnabledCell = {
|
|
8
|
+
mount: string;
|
|
9
|
+
config: CellConfig;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function extractMountFromAuthorizationServerWellKnownPath(pathname: string): string | null {
|
|
13
|
+
if (pathname === AUTHORIZATION_SERVER_WELL_KNOWN_PREFIX) return null;
|
|
14
|
+
if (!pathname.startsWith(`${AUTHORIZATION_SERVER_WELL_KNOWN_PREFIX}/`)) return null;
|
|
15
|
+
const suffix = pathname.slice(`${AUTHORIZATION_SERVER_WELL_KNOWN_PREFIX}/`.length).replace(/\/+$/, "");
|
|
16
|
+
if (!suffix || suffix.includes("/")) return null;
|
|
17
|
+
return suffix;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createOAuthDiscoveryRegistry(cells: OAuthEnabledCell[]): Map<string, { scopes: string[] }> {
|
|
21
|
+
const registry = new Map<string, { scopes: string[] }>();
|
|
22
|
+
for (const cell of cells) {
|
|
23
|
+
const oauth = cell.config.oauth;
|
|
24
|
+
if (!oauth?.enabled) continue;
|
|
25
|
+
registry.set(cell.mount, { scopes: oauth.scopes });
|
|
26
|
+
}
|
|
27
|
+
return registry;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getRequestOrigin(c: Context): string {
|
|
31
|
+
const forwardedHost = c.req.header("X-Forwarded-Host");
|
|
32
|
+
const forwardedProto = c.req.header("X-Forwarded-Proto");
|
|
33
|
+
if (forwardedHost) {
|
|
34
|
+
const proto = forwardedProto ?? (forwardedHost.includes("localhost") ? "http" : "https");
|
|
35
|
+
return `${proto}://${forwardedHost}`.replace(/\/$/, "");
|
|
36
|
+
}
|
|
37
|
+
return new URL(c.req.url).origin.replace(/\/$/, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildOAuthAuthorizationServerMetadata(origin: string, mount: string, scopes: string[]) {
|
|
41
|
+
const cleanOrigin = origin.replace(/\/$/, "");
|
|
42
|
+
const issuer = `${cleanOrigin}/${mount}`;
|
|
43
|
+
return {
|
|
44
|
+
issuer,
|
|
45
|
+
authorization_endpoint: `${issuer}/oauth/authorize`,
|
|
46
|
+
token_endpoint: `${issuer}/oauth/token`,
|
|
47
|
+
registration_endpoint: `${issuer}/oauth/register`,
|
|
48
|
+
response_types_supported: ["code"],
|
|
49
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
50
|
+
code_challenge_methods_supported: ["S256"],
|
|
51
|
+
scopes_supported: scopes,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function extractProtectedResourcePathFromWellKnown(pathname: string): string | null {
|
|
56
|
+
if (pathname === PROTECTED_RESOURCE_WELL_KNOWN_PREFIX) return null;
|
|
57
|
+
if (!pathname.startsWith(`${PROTECTED_RESOURCE_WELL_KNOWN_PREFIX}/`)) return null;
|
|
58
|
+
const suffix = pathname.slice(PROTECTED_RESOURCE_WELL_KNOWN_PREFIX.length).replace(/\/+$/, "");
|
|
59
|
+
if (!suffix || suffix === "/") return null;
|
|
60
|
+
return suffix.startsWith("/") ? suffix : `/${suffix}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildOAuthProtectedResourceMetadata(
|
|
64
|
+
origin: string,
|
|
65
|
+
resourcePath: string,
|
|
66
|
+
mount: string,
|
|
67
|
+
scopes: string[]
|
|
68
|
+
) {
|
|
69
|
+
const cleanOrigin = origin.replace(/\/$/, "");
|
|
70
|
+
return {
|
|
71
|
+
authorization_servers: [`${cleanOrigin}/${mount}`],
|
|
72
|
+
resource: `${cleanOrigin}${resourcePath}`,
|
|
73
|
+
scopes_supported: scopes,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|