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,421 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
import type { OtaviaYaml } from "../../config/otavia-yaml-schema.js";
|
|
6
|
+
import type { CellConfig } from "../../config/cell-yaml-schema.js";
|
|
7
|
+
import { loadOtaviaYaml } from "../../config/load-otavia-yaml.js";
|
|
8
|
+
import { loadCellConfig } from "../../config/load-cell-yaml.js";
|
|
9
|
+
import { resolveCellDir } from "../../config/resolve-cell-dir.js";
|
|
10
|
+
import { assertDeclaredParamsProvided, mergeParams, resolveParams } from "../../config/resolve-params.js";
|
|
11
|
+
import { loadEnvForCell } from "../../utils/env.js";
|
|
12
|
+
import { tablePhysicalName, bucketPhysicalName } from "../../config/resource-names.js";
|
|
13
|
+
import { buildForwardUrlForCellMount } from "./forward-url.js";
|
|
14
|
+
import {
|
|
15
|
+
isDockerRunning,
|
|
16
|
+
startDynamoDB,
|
|
17
|
+
startMinIO,
|
|
18
|
+
waitForPort,
|
|
19
|
+
} from "../../local/docker.js";
|
|
20
|
+
import { isDynamoDBReady, ensureLocalTables, type LocalTableEntry } from "../../local/dynamodb-local.js";
|
|
21
|
+
import { isMinIOReady, ensureLocalBuckets } from "../../local/minio-local.js";
|
|
22
|
+
import {
|
|
23
|
+
buildOAuthAuthorizationServerMetadata,
|
|
24
|
+
buildOAuthProtectedResourceMetadata,
|
|
25
|
+
createOAuthDiscoveryRegistry,
|
|
26
|
+
extractMountFromAuthorizationServerWellKnownPath,
|
|
27
|
+
extractProtectedResourcePathFromWellKnown,
|
|
28
|
+
getRequestOrigin,
|
|
29
|
+
} from "./well-known.js";
|
|
30
|
+
import { resolveRootRedirectMount } from "./mount-selection.js";
|
|
31
|
+
|
|
32
|
+
const DYNAMODB_CONTAINER = "otavia-dynamodb-dev";
|
|
33
|
+
const MINIO_CONTAINER = "otavia-minio-dev";
|
|
34
|
+
|
|
35
|
+
export interface GatewayCellInfo {
|
|
36
|
+
/** Path segment for this cell in the stack (e.g. "sso", "drive"). */
|
|
37
|
+
mount: string;
|
|
38
|
+
cellDir: string;
|
|
39
|
+
/** Package name (e.g. @otavia/sso). Used to import gateway module. */
|
|
40
|
+
packageName: string;
|
|
41
|
+
config: CellConfig;
|
|
42
|
+
env: Record<string, string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolvedParamsToEnv(resolved: Record<string, string | unknown>): Record<string, string> {
|
|
46
|
+
const env: Record<string, string> = {};
|
|
47
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
48
|
+
if (value === null || value === undefined) {
|
|
49
|
+
env[key] = "";
|
|
50
|
+
} else if (typeof value === "object") {
|
|
51
|
+
env[key] = JSON.stringify(value);
|
|
52
|
+
} else {
|
|
53
|
+
env[key] = String(value);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return env;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function resolveGatewaySsoBaseUrl(
|
|
60
|
+
configuredSsoBaseUrl: string | undefined,
|
|
61
|
+
backendPort: number,
|
|
62
|
+
defaultMount: string,
|
|
63
|
+
publicBaseUrl?: string
|
|
64
|
+
): string {
|
|
65
|
+
const configured = configuredSsoBaseUrl?.trim();
|
|
66
|
+
if (configured) {
|
|
67
|
+
if (publicBaseUrl) {
|
|
68
|
+
try {
|
|
69
|
+
const parsed = new URL(configured);
|
|
70
|
+
const host = parsed.hostname.toLowerCase();
|
|
71
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "::1") {
|
|
72
|
+
return `${publicBaseUrl.replace(/\/$/, "")}/${defaultMount}`;
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Keep configured value if it is not a URL.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return configured;
|
|
79
|
+
}
|
|
80
|
+
if (publicBaseUrl) return `${publicBaseUrl.replace(/\/$/, "")}/${defaultMount}`;
|
|
81
|
+
return `http://localhost:${backendPort}/${defaultMount}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function toResourceEnvKey(prefix: string, key: string): string {
|
|
85
|
+
const normalized = key.replace(/[^A-Za-z0-9]/g, "_").toUpperCase();
|
|
86
|
+
return `${prefix}${normalized}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function applyResourceNameEnvVars(cells: GatewayCellInfo[], stackName: string): void {
|
|
90
|
+
for (const cell of cells) {
|
|
91
|
+
if (cell.config.tables) {
|
|
92
|
+
for (const key of Object.keys(cell.config.tables)) {
|
|
93
|
+
cell.env[toResourceEnvKey("DYNAMODB_TABLE_", key)] = tablePhysicalName(
|
|
94
|
+
stackName,
|
|
95
|
+
cell.mount,
|
|
96
|
+
key
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (cell.config.buckets) {
|
|
101
|
+
for (const key of Object.keys(cell.config.buckets)) {
|
|
102
|
+
cell.env[toResourceEnvKey("S3_BUCKET_", key)] = bucketPhysicalName(
|
|
103
|
+
stackName,
|
|
104
|
+
cell.mount,
|
|
105
|
+
key
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function discoverCells(
|
|
113
|
+
rootDir: string,
|
|
114
|
+
otavia: OtaviaYaml,
|
|
115
|
+
backendPort: number,
|
|
116
|
+
publicBaseUrl?: string
|
|
117
|
+
): Promise<GatewayCellInfo[]> {
|
|
118
|
+
const firstMount = otavia.cellsList[0]?.mount ?? "";
|
|
119
|
+
const cells: GatewayCellInfo[] = [];
|
|
120
|
+
|
|
121
|
+
for (const entry of otavia.cellsList) {
|
|
122
|
+
const cellDir = resolveCellDir(rootDir, entry.package);
|
|
123
|
+
const cellYamlPath = resolve(cellDir, "cell.yaml");
|
|
124
|
+
if (!existsSync(cellYamlPath)) {
|
|
125
|
+
console.warn(`[gateway] Skipping "${entry.mount}" (${entry.package}): cell.yaml not found at ${cellYamlPath}`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const pkgPath = resolve(cellDir, "package.json");
|
|
129
|
+
if (!existsSync(pkgPath)) {
|
|
130
|
+
console.warn(`[gateway] Skipping "${entry.mount}": package.json not found`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { name?: string };
|
|
134
|
+
const packageName = pkg?.name ?? entry.package;
|
|
135
|
+
const config = loadCellConfig(cellDir);
|
|
136
|
+
const merged = mergeParams(otavia.params, entry.params);
|
|
137
|
+
assertDeclaredParamsProvided(config.params, merged, entry.mount);
|
|
138
|
+
const envMap = loadEnvForCell(rootDir, cellDir, { stage: "dev" });
|
|
139
|
+
if (!envMap.SSO_BASE_URL?.trim()) {
|
|
140
|
+
envMap.SSO_BASE_URL = resolveGatewaySsoBaseUrl(undefined, backendPort, firstMount, publicBaseUrl);
|
|
141
|
+
}
|
|
142
|
+
const resolved = resolveParams(merged as Record<string, unknown>, envMap, {
|
|
143
|
+
onMissingParam: "placeholder",
|
|
144
|
+
});
|
|
145
|
+
const env = resolvedParamsToEnv(resolved as Record<string, string | unknown>);
|
|
146
|
+
const ssoBaseUrl = resolveGatewaySsoBaseUrl(env.SSO_BASE_URL, backendPort, firstMount, publicBaseUrl);
|
|
147
|
+
env.CELL_BASE_URL =
|
|
148
|
+
entry.mount === "sso"
|
|
149
|
+
? ssoBaseUrl
|
|
150
|
+
: publicBaseUrl
|
|
151
|
+
? `${publicBaseUrl.replace(/\/$/, "")}/${entry.mount}`
|
|
152
|
+
: `http://localhost:${backendPort}/${entry.mount}`;
|
|
153
|
+
env.SSO_BASE_URL = ssoBaseUrl;
|
|
154
|
+
cells.push({ mount: entry.mount, cellDir, packageName, config, env });
|
|
155
|
+
}
|
|
156
|
+
return cells;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function ensureDockerResources(
|
|
160
|
+
rootDir: string,
|
|
161
|
+
otavia: OtaviaYaml,
|
|
162
|
+
cells: GatewayCellInfo[],
|
|
163
|
+
options: { dynamodbPort: number; minioPort: number }
|
|
164
|
+
): Promise<{ dynamoEndpoint?: string; s3Endpoint?: string }> {
|
|
165
|
+
const hasTables = cells.some((c) => c.config.tables && Object.keys(c.config.tables).length > 0);
|
|
166
|
+
const hasBuckets = cells.some((c) => c.config.buckets && Object.keys(c.config.buckets).length > 0);
|
|
167
|
+
if (!hasTables && !hasBuckets) return {};
|
|
168
|
+
|
|
169
|
+
if (!(await isDockerRunning())) {
|
|
170
|
+
throw new Error("Docker is not running. Start Docker to use local DynamoDB/MinIO.");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const stackName = otavia.stackName;
|
|
174
|
+
let dynamoEndpoint: string | undefined;
|
|
175
|
+
let s3Endpoint: string | undefined;
|
|
176
|
+
|
|
177
|
+
if (hasTables) {
|
|
178
|
+
await startDynamoDB({
|
|
179
|
+
port: options.dynamodbPort,
|
|
180
|
+
persistent: false,
|
|
181
|
+
containerName: DYNAMODB_CONTAINER,
|
|
182
|
+
});
|
|
183
|
+
if (!(await waitForPort(options.dynamodbPort))) {
|
|
184
|
+
throw new Error("DynamoDB Local did not become ready in time");
|
|
185
|
+
}
|
|
186
|
+
dynamoEndpoint = `http://localhost:${options.dynamodbPort}`;
|
|
187
|
+
// DynamoDB Local may accept TCP before the API is ready; retry isDynamoDBReady
|
|
188
|
+
for (let i = 0; i < 30; i++) {
|
|
189
|
+
if (await isDynamoDBReady(dynamoEndpoint)) break;
|
|
190
|
+
await Bun.sleep(500);
|
|
191
|
+
if (i === 29) {
|
|
192
|
+
throw new Error("DynamoDB endpoint not accepting requests");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const tablesList: LocalTableEntry[] = [];
|
|
196
|
+
for (const cell of cells) {
|
|
197
|
+
if (!cell.config.tables) continue;
|
|
198
|
+
for (const [key, config] of Object.entries(cell.config.tables)) {
|
|
199
|
+
tablesList.push({
|
|
200
|
+
tableName: tablePhysicalName(stackName, cell.mount, key),
|
|
201
|
+
config,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
await ensureLocalTables(dynamoEndpoint, tablesList);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (hasBuckets) {
|
|
209
|
+
await startMinIO({
|
|
210
|
+
port: options.minioPort,
|
|
211
|
+
containerName: MINIO_CONTAINER,
|
|
212
|
+
// no dataDir for dev => ephemeral
|
|
213
|
+
});
|
|
214
|
+
if (!(await waitForPort(options.minioPort))) {
|
|
215
|
+
throw new Error("MinIO did not become ready in time");
|
|
216
|
+
}
|
|
217
|
+
s3Endpoint = `http://localhost:${options.minioPort}`;
|
|
218
|
+
// MinIO may accept TCP before the S3 API is ready; retry isMinIOReady
|
|
219
|
+
for (let i = 0; i < 30; i++) {
|
|
220
|
+
if (await isMinIOReady(s3Endpoint)) break;
|
|
221
|
+
await Bun.sleep(500);
|
|
222
|
+
if (i === 29) {
|
|
223
|
+
throw new Error("MinIO endpoint not accepting S3 requests");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const bucketNames: string[] = [];
|
|
227
|
+
for (const cell of cells) {
|
|
228
|
+
if (!cell.config.buckets) continue;
|
|
229
|
+
for (const key of Object.keys(cell.config.buckets)) {
|
|
230
|
+
bucketNames.push(bucketPhysicalName(stackName, cell.mount, key));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
await ensureLocalBuckets(s3Endpoint, bucketNames);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { dynamoEndpoint, s3Endpoint };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function applyLocalEndpoints(
|
|
240
|
+
cells: GatewayCellInfo[],
|
|
241
|
+
dynamoEndpoint?: string,
|
|
242
|
+
s3Endpoint?: string
|
|
243
|
+
): void {
|
|
244
|
+
for (const cell of cells) {
|
|
245
|
+
if (dynamoEndpoint && cell.config.tables && Object.keys(cell.config.tables).length > 0) {
|
|
246
|
+
cell.env.DYNAMODB_ENDPOINT = dynamoEndpoint;
|
|
247
|
+
}
|
|
248
|
+
if (s3Endpoint && cell.config.buckets && Object.keys(cell.config.buckets).length > 0) {
|
|
249
|
+
cell.env.S3_ENDPOINT = s3Endpoint;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function loadCellGatewayApp(
|
|
255
|
+
cell: GatewayCellInfo
|
|
256
|
+
): Promise<((env: Record<string, string>) => Hono | Promise<Hono>) | null> {
|
|
257
|
+
// Preferred: package export contract "@pkg/backend"
|
|
258
|
+
try {
|
|
259
|
+
const mod = await import(`${cell.packageName}/backend`);
|
|
260
|
+
if (typeof mod?.createAppForBackend === "function") {
|
|
261
|
+
return mod.createAppForBackend;
|
|
262
|
+
}
|
|
263
|
+
if (typeof mod?.createAppForGateway === "function") {
|
|
264
|
+
return mod.createAppForGateway;
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
// Fallback to file-based discovery.
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Compatibility fallback while migrating from gateway naming.
|
|
271
|
+
const candidates = [
|
|
272
|
+
resolve(cell.cellDir, "backend", "app.ts"),
|
|
273
|
+
resolve(cell.cellDir, "backend", "gateway-app.ts"),
|
|
274
|
+
];
|
|
275
|
+
for (const backendEntryPath of candidates) {
|
|
276
|
+
if (!existsSync(backendEntryPath)) continue;
|
|
277
|
+
try {
|
|
278
|
+
const mod = await import(pathToFileURL(backendEntryPath).href);
|
|
279
|
+
if (typeof mod?.createAppForBackend === "function") {
|
|
280
|
+
return mod.createAppForBackend;
|
|
281
|
+
}
|
|
282
|
+
if (typeof mod?.createAppForGateway === "function") {
|
|
283
|
+
return mod.createAppForGateway;
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
// Module load error, try next candidate.
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export type GatewayServer = { stop: () => void };
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Start the dev gateway: single Hono app mounting each cell at /<mount>.
|
|
296
|
+
* Starts Docker (DynamoDB Local + MinIO) when any cell has tables/buckets, unless
|
|
297
|
+
* overrides are provided (e.g. for e2e: caller already started Docker and passes endpoints).
|
|
298
|
+
*/
|
|
299
|
+
export async function runGatewayDev(
|
|
300
|
+
rootDir: string,
|
|
301
|
+
backendPort: number,
|
|
302
|
+
overrides?: { dynamoEndpoint?: string; s3Endpoint?: string },
|
|
303
|
+
options?: { publicBaseUrl?: string; dynamodbPort: number; minioPort: number }
|
|
304
|
+
): Promise<GatewayServer> {
|
|
305
|
+
const otavia = loadOtaviaYaml(rootDir);
|
|
306
|
+
const cells = await discoverCells(rootDir, otavia, backendPort, options?.publicBaseUrl);
|
|
307
|
+
if (cells.length === 0) {
|
|
308
|
+
throw new Error("No cells found");
|
|
309
|
+
}
|
|
310
|
+
applyResourceNameEnvVars(cells, otavia.stackName);
|
|
311
|
+
|
|
312
|
+
let dynamoEndpoint: string | undefined;
|
|
313
|
+
let s3Endpoint: string | undefined;
|
|
314
|
+
if (overrides?.dynamoEndpoint !== undefined || overrides?.s3Endpoint !== undefined) {
|
|
315
|
+
dynamoEndpoint = overrides.dynamoEndpoint;
|
|
316
|
+
s3Endpoint = overrides.s3Endpoint;
|
|
317
|
+
} else {
|
|
318
|
+
if (!options) {
|
|
319
|
+
throw new Error("Missing docker port options for local resources.");
|
|
320
|
+
}
|
|
321
|
+
const resources = await ensureDockerResources(rootDir, otavia, cells, {
|
|
322
|
+
dynamodbPort: options.dynamodbPort,
|
|
323
|
+
minioPort: options.minioPort,
|
|
324
|
+
});
|
|
325
|
+
dynamoEndpoint = resources.dynamoEndpoint;
|
|
326
|
+
s3Endpoint = resources.s3Endpoint;
|
|
327
|
+
}
|
|
328
|
+
applyLocalEndpoints(cells, dynamoEndpoint, s3Endpoint);
|
|
329
|
+
|
|
330
|
+
const gatewayApp = new Hono();
|
|
331
|
+
const firstMount = otavia.cellsList[0]?.mount ?? "";
|
|
332
|
+
const rootRedirectMount = resolveRootRedirectMount(
|
|
333
|
+
otavia.cellsList.map((cell) => cell.mount),
|
|
334
|
+
otavia.defaultCell
|
|
335
|
+
);
|
|
336
|
+
const oauthDiscoveryRegistry = createOAuthDiscoveryRegistry(cells);
|
|
337
|
+
|
|
338
|
+
gatewayApp.get("/", (c) => c.redirect(`/${rootRedirectMount}/`, 301));
|
|
339
|
+
|
|
340
|
+
gatewayApp.get("/.well-known/oauth-authorization-server", (c) => {
|
|
341
|
+
return c.json({ error: "not_found", message: "issuer path suffix is required" }, 404);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
gatewayApp.get("/.well-known/oauth-authorization-server/*", (c) => {
|
|
345
|
+
const mount = extractMountFromAuthorizationServerWellKnownPath(c.req.path);
|
|
346
|
+
if (!mount) {
|
|
347
|
+
return c.json({ error: "not_found" }, 404);
|
|
348
|
+
}
|
|
349
|
+
const oauthCell = oauthDiscoveryRegistry.get(mount);
|
|
350
|
+
if (!oauthCell) {
|
|
351
|
+
return c.json({ error: "not_found" }, 404);
|
|
352
|
+
}
|
|
353
|
+
const origin = getRequestOrigin(c);
|
|
354
|
+
return c.json(buildOAuthAuthorizationServerMetadata(origin, mount, oauthCell.scopes));
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
gatewayApp.get("/.well-known/oauth-protected-resource", (c) => {
|
|
358
|
+
return c.json({ error: "not_found", message: "resource path suffix is required" }, 404);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
gatewayApp.get("/.well-known/oauth-protected-resource/*", (c) => {
|
|
362
|
+
const resourcePath = extractProtectedResourcePathFromWellKnown(c.req.path);
|
|
363
|
+
if (!resourcePath) {
|
|
364
|
+
return c.json({ error: "not_found" }, 404);
|
|
365
|
+
}
|
|
366
|
+
const mount = resourcePath.split("/").filter(Boolean)[0];
|
|
367
|
+
if (!mount) {
|
|
368
|
+
return c.json({ error: "not_found" }, 404);
|
|
369
|
+
}
|
|
370
|
+
const oauthCell = oauthDiscoveryRegistry.get(mount);
|
|
371
|
+
if (!oauthCell) {
|
|
372
|
+
return c.json({ error: "not_found" }, 404);
|
|
373
|
+
}
|
|
374
|
+
const origin = getRequestOrigin(c);
|
|
375
|
+
return c.json(buildOAuthProtectedResourceMetadata(origin, resourcePath, mount, oauthCell.scopes));
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
for (const cell of cells) {
|
|
379
|
+
gatewayApp.get(`/${cell.mount}`, (c) => c.redirect(`/${cell.mount}/`, 301));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
for (const cell of cells) {
|
|
383
|
+
const createApp = await loadCellGatewayApp(cell);
|
|
384
|
+
if (!createApp) {
|
|
385
|
+
console.warn(
|
|
386
|
+
`[gateway] No backend entry for "${cell.mount}" (tried backend/app.ts and backend/gateway-app.ts), skipping mount`
|
|
387
|
+
);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const cellApp = await Promise.resolve(createApp(cell.env));
|
|
391
|
+
const prefix = `/${cell.mount}`;
|
|
392
|
+
gatewayApp.all(prefix + "/", async (c) => {
|
|
393
|
+
const newUrl = buildForwardUrlForCellMount(c.req.url, prefix);
|
|
394
|
+
const newReq = new Request(newUrl.href, {
|
|
395
|
+
method: c.req.method,
|
|
396
|
+
headers: c.req.raw.headers,
|
|
397
|
+
body: c.req.raw.body,
|
|
398
|
+
});
|
|
399
|
+
return cellApp.fetch(newReq);
|
|
400
|
+
});
|
|
401
|
+
gatewayApp.all(`${prefix}/*`, async (c) => {
|
|
402
|
+
const newUrl = buildForwardUrlForCellMount(c.req.url, prefix);
|
|
403
|
+
const newReq = new Request(newUrl.href, {
|
|
404
|
+
method: c.req.method,
|
|
405
|
+
headers: c.req.raw.headers,
|
|
406
|
+
body: c.req.raw.body,
|
|
407
|
+
});
|
|
408
|
+
return cellApp.fetch(newReq);
|
|
409
|
+
});
|
|
410
|
+
console.log(`[gateway] Mounted ${cell.mount} at /${cell.mount}`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const server = Bun.serve({
|
|
414
|
+
port: backendPort,
|
|
415
|
+
hostname: "0.0.0.0",
|
|
416
|
+
fetch: gatewayApp.fetch,
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
console.log(`[gateway] Gateway running at http://localhost:${server.port}`);
|
|
420
|
+
return { stop: () => server.stop() };
|
|
421
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type MountLoaders = Record<string, () => Promise<unknown>>;
|
|
2
|
+
|
|
3
|
+
function normalizePathname(pathname: string): string {
|
|
4
|
+
if (!pathname.endsWith("/")) return pathname + "/";
|
|
5
|
+
return pathname;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function resolveMount(pathname: string, mounts: string[]): string | null {
|
|
9
|
+
const seg = pathname.split("/").filter(Boolean)[0];
|
|
10
|
+
if (!seg) return null;
|
|
11
|
+
return mounts.includes(seg) ? seg : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function bootMainFrontend(
|
|
15
|
+
rootRedirectMount: string,
|
|
16
|
+
mounts: string[],
|
|
17
|
+
mountLoaders: MountLoaders
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
const mount = resolveMount(window.location.pathname, mounts);
|
|
20
|
+
if (!mount) {
|
|
21
|
+
window.location.replace(`/${rootRedirectMount}/`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const desiredPrefix = `/${mount}/`;
|
|
25
|
+
if (!normalizePathname(window.location.pathname).startsWith(desiredPrefix)) {
|
|
26
|
+
window.location.replace(desiredPrefix);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const load = mountLoaders[mount];
|
|
30
|
+
if (!load) {
|
|
31
|
+
window.location.replace(`/${rootRedirectMount}/`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await load();
|
|
35
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve as resolvePath } from "node:path";
|
|
3
|
+
import { defineConfig, type Plugin } from "vite";
|
|
4
|
+
import react from "@vitejs/plugin-react";
|
|
5
|
+
|
|
6
|
+
export type RouteMatch = "prefix" | "exact";
|
|
7
|
+
export type RouteRule = { path: string; match: RouteMatch };
|
|
8
|
+
export type ProxyRule = { mount: string; path: string; match: RouteMatch; target: string };
|
|
9
|
+
export type FrontendModuleProxyRule = { path: string; sourcePath: string };
|
|
10
|
+
export type MainDevGeneratedConfig = {
|
|
11
|
+
firstMount: string;
|
|
12
|
+
mounts: string[];
|
|
13
|
+
routeRules: RouteRule[];
|
|
14
|
+
proxyRules: ProxyRule[];
|
|
15
|
+
frontendModuleProxyRules: FrontendModuleProxyRule[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function isRouteMatch(v: unknown): v is RouteMatch {
|
|
19
|
+
return v === "prefix" || v === "exact";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isRouteRule(v: unknown): v is RouteRule {
|
|
23
|
+
return (
|
|
24
|
+
typeof v === "object" &&
|
|
25
|
+
v !== null &&
|
|
26
|
+
typeof (v as RouteRule).path === "string" &&
|
|
27
|
+
isRouteMatch((v as RouteRule).match)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isProxyRule(v: unknown): v is ProxyRule {
|
|
32
|
+
return (
|
|
33
|
+
typeof v === "object" &&
|
|
34
|
+
v !== null &&
|
|
35
|
+
typeof (v as ProxyRule).mount === "string" &&
|
|
36
|
+
typeof (v as ProxyRule).path === "string" &&
|
|
37
|
+
isRouteMatch((v as ProxyRule).match) &&
|
|
38
|
+
typeof (v as ProxyRule).target === "string"
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isFrontendModuleProxyRule(v: unknown): v is FrontendModuleProxyRule {
|
|
43
|
+
return (
|
|
44
|
+
typeof v === "object" &&
|
|
45
|
+
v !== null &&
|
|
46
|
+
typeof (v as FrontendModuleProxyRule).path === "string" &&
|
|
47
|
+
typeof (v as FrontendModuleProxyRule).sourcePath === "string"
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function loadGeneratedConfig(generatedConfigPath: URL): MainDevGeneratedConfig | null {
|
|
52
|
+
if (!existsSync(generatedConfigPath)) return null;
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(readFileSync(generatedConfigPath, "utf-8")) as Partial<MainDevGeneratedConfig>;
|
|
55
|
+
if (
|
|
56
|
+
!Array.isArray(parsed.mounts) ||
|
|
57
|
+
!Array.isArray(parsed.routeRules) ||
|
|
58
|
+
!Array.isArray(parsed.proxyRules)
|
|
59
|
+
) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const mounts = parsed.mounts.filter((m): m is string => typeof m === "string");
|
|
63
|
+
const routeRules = parsed.routeRules.filter(isRouteRule);
|
|
64
|
+
const proxyRules = parsed.proxyRules.filter(isProxyRule);
|
|
65
|
+
const frontendModuleProxyRules = Array.isArray(parsed.frontendModuleProxyRules)
|
|
66
|
+
? parsed.frontendModuleProxyRules.filter(isFrontendModuleProxyRule)
|
|
67
|
+
: [];
|
|
68
|
+
const firstMount = typeof parsed.firstMount === "string" ? parsed.firstMount : mounts[0] ?? "";
|
|
69
|
+
return { firstMount, mounts, routeRules, proxyRules, frontendModuleProxyRules };
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function toAbsoluteFsPath(sourcePath: string, packageRoot: string): string {
|
|
76
|
+
const isWindowsAbs = /^[A-Za-z]:[\\/]/.test(sourcePath);
|
|
77
|
+
const absolute = sourcePath.startsWith("/") || isWindowsAbs
|
|
78
|
+
? sourcePath
|
|
79
|
+
: resolvePath(packageRoot, sourcePath);
|
|
80
|
+
return absolute.replace(/\\/g, "/");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function extractMountFromPath(pathname: string, mountSet: Set<string>): string | null {
|
|
84
|
+
const seg = pathname.split("/").filter(Boolean)[0];
|
|
85
|
+
if (!seg) return null;
|
|
86
|
+
return mountSet.has(seg) ? seg : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function matchesRule(pathname: string, rule: RouteRule): boolean {
|
|
90
|
+
return rule.match === "exact"
|
|
91
|
+
? pathname === rule.path
|
|
92
|
+
: pathname === rule.path || pathname.startsWith(rule.path + "/");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isBackendRoute(pathname: string, routeRules: RouteRule[]): boolean {
|
|
96
|
+
return routeRules.some((r) => matchesRule(pathname, r));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isGlobalWellKnownPath(pathname: string): boolean {
|
|
100
|
+
return pathname === "/.well-known" || pathname.startsWith("/.well-known/");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type ConfigOptions = {
|
|
104
|
+
generatedConfigPath: URL;
|
|
105
|
+
packageRoot: string;
|
|
106
|
+
backendPort: string;
|
|
107
|
+
vitePort: number;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export function createMainFrontendViteConfig(options: ConfigOptions) {
|
|
111
|
+
const backendTarget = `http://localhost:${options.backendPort}`;
|
|
112
|
+
const generated = loadGeneratedConfig(options.generatedConfigPath);
|
|
113
|
+
if (!generated) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Missing or invalid generated dev config at ${options.generatedConfigPath.href}. Run otavia dev to regenerate it.`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const mounts: string[] = generated.mounts;
|
|
119
|
+
const mountSet = new Set(mounts);
|
|
120
|
+
const firstMount = generated.firstMount;
|
|
121
|
+
const routeRules: RouteRule[] = generated.routeRules;
|
|
122
|
+
const frontendModuleProxyRules = generated.frontendModuleProxyRules;
|
|
123
|
+
const frontendModuleProxyMap = new Map(frontendModuleProxyRules.map((r) => [r.path, r.sourcePath]));
|
|
124
|
+
|
|
125
|
+
function mountAwareApiRewritePlugin(): Plugin {
|
|
126
|
+
return {
|
|
127
|
+
name: "otavia-mount-aware-api-rewrite",
|
|
128
|
+
configureServer(server) {
|
|
129
|
+
server.middlewares.use((req, _res, next) => {
|
|
130
|
+
const url = req.url ?? "/";
|
|
131
|
+
const parsed = new URL(url, "http://localhost");
|
|
132
|
+
const pathname = parsed.pathname;
|
|
133
|
+
const moduleSourcePath = frontendModuleProxyMap.get(pathname);
|
|
134
|
+
if (moduleSourcePath) {
|
|
135
|
+
req.url = `/@fs/${toAbsoluteFsPath(moduleSourcePath, options.packageRoot)}${parsed.search}`;
|
|
136
|
+
next();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (isGlobalWellKnownPath(pathname)) {
|
|
141
|
+
next();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const alreadyMounted = extractMountFromPath(pathname, mountSet);
|
|
146
|
+
if (alreadyMounted) {
|
|
147
|
+
next();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (!isBackendRoute(pathname, routeRules)) {
|
|
151
|
+
next();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const referer = req.headers.referer;
|
|
156
|
+
let mount = firstMount;
|
|
157
|
+
if (referer) {
|
|
158
|
+
try {
|
|
159
|
+
const refPath = new URL(referer).pathname;
|
|
160
|
+
const refMount = extractMountFromPath(refPath, mountSet);
|
|
161
|
+
if (refMount) mount = refMount;
|
|
162
|
+
} catch {
|
|
163
|
+
// Ignore malformed referer.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (mount) {
|
|
167
|
+
req.url = `/${mount}${pathname}${parsed.search}`;
|
|
168
|
+
}
|
|
169
|
+
next();
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const proxy: Record<string, object> = {};
|
|
176
|
+
const proxyRules: ProxyRule[] = generated.proxyRules;
|
|
177
|
+
const sortedProxyRules = proxyRules.slice().sort((a, b) => {
|
|
178
|
+
if (a.path === b.path) {
|
|
179
|
+
if (a.match === b.match) return 0;
|
|
180
|
+
return a.match === "exact" ? -1 : 1;
|
|
181
|
+
}
|
|
182
|
+
return b.path.length - a.path.length;
|
|
183
|
+
});
|
|
184
|
+
for (const rule of sortedProxyRules) {
|
|
185
|
+
if (proxy[rule.path]) continue;
|
|
186
|
+
if (rule.match === "exact") {
|
|
187
|
+
proxy[rule.path] = {
|
|
188
|
+
target: rule.target,
|
|
189
|
+
bypass(req: { url?: string }) {
|
|
190
|
+
const pathname = req.url?.split("?")[0] ?? "";
|
|
191
|
+
if (pathname !== rule.path) return "/index.html";
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
} else {
|
|
195
|
+
proxy[rule.path] = { target: rule.target };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return defineConfig({
|
|
200
|
+
plugins: [mountAwareApiRewritePlugin(), react()],
|
|
201
|
+
resolve: { conditions: ["bun"], dedupe: ["react", "react-dom"] },
|
|
202
|
+
server: {
|
|
203
|
+
port: options.vitePort,
|
|
204
|
+
host: "0.0.0.0",
|
|
205
|
+
allowedHosts: true,
|
|
206
|
+
strictPort: true,
|
|
207
|
+
proxy,
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}
|