@veducio/keja 0.0.0-stage → 0.3.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/LICENSE +7 -0
- package/README.md +39 -2
- package/examples/starter/.env.example +6 -0
- package/examples/starter/keja.config.ts +7 -0
- package/examples/starter/keja.versioning.ts +5 -0
- package/examples/starter/migrations/0001_init.sql +1 -0
- package/examples/starter/package.json +14 -0
- package/examples/starter/tsconfig.json +9 -0
- package/package.json +62 -4
- package/src/access/cloudflare.test.ts +131 -0
- package/src/access/cloudflare.ts +193 -0
- package/src/argus/call.test.ts +98 -0
- package/src/argus/call.ts +153 -0
- package/src/argus/heartbeat.ts +65 -0
- package/src/argus/register.test.ts +120 -0
- package/src/argus/register.ts +145 -0
- package/src/cli/banner.test.ts +98 -0
- package/src/cli/banner.ts +209 -0
- package/src/cli/commands/build.ts +12 -0
- package/src/cli/commands/dev.ts +12 -0
- package/src/cli/commands/generate.ts +12 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/migrate.ts +12 -0
- package/src/cli/commands/seed.ts +12 -0
- package/src/cli/commands/start.ts +12 -0
- package/src/cli/dev-reload.test.ts +87 -0
- package/src/cli/dev-reload.ts +182 -0
- package/src/cli/generate-project.ts +39 -0
- package/src/cli/index.ts +29 -0
- package/src/cli/init-project.test.ts +312 -0
- package/src/cli/init-project.ts +416 -0
- package/src/cli/migrate-project.ts +31 -0
- package/src/cli/run-project.test.ts +50 -0
- package/src/cli/run-project.ts +93 -0
- package/src/cli/seed-project.ts +46 -0
- package/src/db/client.test.ts +49 -0
- package/src/db/client.ts +61 -0
- package/src/db/migrate.test.ts +141 -0
- package/src/db/migrate.ts +146 -0
- package/src/db/owned-sql.ts +202 -0
- package/src/db/postgres.ts +90 -0
- package/src/env/required-env.test.ts +64 -0
- package/src/env/required-env.ts +120 -0
- package/src/errors/framework-errors.ts +295 -0
- package/src/grant/grant.test.ts +130 -0
- package/src/grant/grant.ts +268 -0
- package/src/http/create-app.test.ts +340 -0
- package/src/http/create-app.ts +196 -0
- package/src/http/openapi.ts +111 -0
- package/src/process/slot.ts +18 -0
- package/src/project/config.test.ts +143 -0
- package/src/project/config.ts +450 -0
- package/src/project/load.test.ts +60 -0
- package/src/project/load.ts +158 -0
- package/src/project/reload-graph.test.ts +52 -0
- package/src/project/reload-graph.ts +175 -0
- package/src/repository/define.ts +123 -0
- package/src/repository/generate.test.ts +58 -0
- package/src/repository/generate.ts +117 -0
- package/src/repository/query.test.ts +99 -0
- package/src/repository/query.ts +287 -0
- package/src/repository/runtime.test.ts +154 -0
- package/src/repository/runtime.ts +434 -0
- package/src/repository/sql.test.ts +78 -0
- package/src/repository/sql.ts +282 -0
- package/src/route/define.test.ts +65 -0
- package/src/route/define.ts +307 -0
- package/src/route/middleware.test.ts +63 -0
- package/src/route/middleware.ts +144 -0
- package/src/seed/apply.test.ts +65 -0
- package/src/seed/apply.ts +11 -0
- package/src/seed/define.test.ts +51 -0
- package/src/seed/define.ts +55 -0
- package/src/seed/load.test.ts +54 -0
- package/src/seed/load.ts +67 -0
- package/src/seed/report.test.ts +16 -0
- package/src/seed/report.ts +15 -0
- package/src/self-test/checks.ts +88 -0
- package/src/self-test/run.test.ts +128 -0
- package/src/self-test/run.ts +105 -0
- package/src/server/identity.test.ts +57 -0
- package/src/server/identity.ts +116 -0
- package/src/server/index.test.ts +15 -0
- package/src/server/index.ts +171 -0
- package/src/server/swap.test.ts +89 -0
- package/src/server/swap.ts +29 -0
- package/src/typeid/id.test.ts +46 -0
- package/src/typeid/id.ts +136 -0
- package/src/typeid/owned.test.ts +73 -0
- package/src/typeid/owned.ts +193 -0
- package/src/vault/client.test.ts +128 -0
- package/src/vault/client.ts +282 -0
- package/src/versioning/define.test.ts +79 -0
- package/src/versioning/define.ts +173 -0
- package/src/versioning/select.test.ts +79 -0
- package/src/versioning/select.ts +62 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Chalk, type ChalkInstance } from "chalk";
|
|
2
|
+
import {
|
|
3
|
+
type BunCheck,
|
|
4
|
+
type BunRuntime,
|
|
5
|
+
checkBun,
|
|
6
|
+
checkEnv,
|
|
7
|
+
checkMachine,
|
|
8
|
+
type MachineCheck,
|
|
9
|
+
type MachineInfo,
|
|
10
|
+
} from "./checks.ts";
|
|
11
|
+
|
|
12
|
+
export type SelfTestResult =
|
|
13
|
+
| {
|
|
14
|
+
ok: false;
|
|
15
|
+
step: "bun" | "machine" | "env";
|
|
16
|
+
error: string;
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
ok: true;
|
|
20
|
+
bun: Extract<BunCheck, { ok: true }>;
|
|
21
|
+
machine: Extract<MachineCheck, { ok: true }>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type SelfTestOptions = {
|
|
25
|
+
runtime?: BunRuntime | undefined;
|
|
26
|
+
machine?: MachineInfo;
|
|
27
|
+
env?: Record<string, string | undefined>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function runSelfTest(options: SelfTestOptions = {}): SelfTestResult {
|
|
31
|
+
const runtime = Object.hasOwn(options, "runtime")
|
|
32
|
+
? options.runtime
|
|
33
|
+
: globalThis.Bun;
|
|
34
|
+
const bun = checkBun(runtime);
|
|
35
|
+
if (!bun.ok) {
|
|
36
|
+
return { ok: false, step: "bun", error: bun.error };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const machine = checkMachine(options.machine);
|
|
40
|
+
if (!machine.ok) {
|
|
41
|
+
return { ok: false, step: "machine", error: machine.error };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const env = options.env ?? runtime?.env ?? process.env;
|
|
45
|
+
const checkedEnv = checkEnv(env);
|
|
46
|
+
if (!checkedEnv.ok) {
|
|
47
|
+
return { ok: false, step: "env", error: checkedEnv.error };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { ok: true, bun, machine };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatSelfTest(result: SelfTestResult, color = false): string {
|
|
54
|
+
const style = ink(color);
|
|
55
|
+
const heading = ` ${style.dim("SELF TEST")}`;
|
|
56
|
+
if (!result.ok) {
|
|
57
|
+
const details = result.error
|
|
58
|
+
.split("\n")
|
|
59
|
+
.map((line) => ` ${line}`)
|
|
60
|
+
.join("\n");
|
|
61
|
+
return `\n${heading}\n\n ${style.red(`x ${stepLabel(result.step)}`)}\n${details}\n\n ${style.red("failed, exiting")}\n`;
|
|
62
|
+
}
|
|
63
|
+
return [
|
|
64
|
+
"",
|
|
65
|
+
heading,
|
|
66
|
+
"",
|
|
67
|
+
row("Bun", result.bun.version, style),
|
|
68
|
+
row(
|
|
69
|
+
"Machine",
|
|
70
|
+
`${result.machine.platform} ${result.machine.arch} ${result.machine.hostname}`,
|
|
71
|
+
style,
|
|
72
|
+
),
|
|
73
|
+
row("Env", "ready", style),
|
|
74
|
+
"",
|
|
75
|
+
` ${style.green("successful")}`,
|
|
76
|
+
"",
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function stepLabel(step: "bun" | "machine" | "env") {
|
|
81
|
+
if (step === "bun") {
|
|
82
|
+
return "Bun";
|
|
83
|
+
}
|
|
84
|
+
if (step === "machine") {
|
|
85
|
+
return "Machine";
|
|
86
|
+
}
|
|
87
|
+
return "Env";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function row(label: string, value: string, style: ChalkInstance) {
|
|
91
|
+
return ` ${style.dim(`- ${label.padEnd(8)}`)} ${value}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function ink(color: boolean) {
|
|
95
|
+
return new Chalk({ level: color ? 1 : 0 });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (import.meta.main) {
|
|
99
|
+
const result = runSelfTest();
|
|
100
|
+
if (!result.ok) {
|
|
101
|
+
console.error(formatSelfTest(result, process.stderr.isTTY === true));
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
console.log(formatSelfTest(result, process.stdout.isTTY === true));
|
|
105
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
accessibleHosts,
|
|
7
|
+
buildIdentity,
|
|
8
|
+
readServiceName,
|
|
9
|
+
serviceName,
|
|
10
|
+
} from "./identity.ts";
|
|
11
|
+
|
|
12
|
+
const lan = {
|
|
13
|
+
Ethernet: [
|
|
14
|
+
{ address: "192.168.1.10", family: "IPv4", internal: false },
|
|
15
|
+
{ address: "fe80::1", family: "IPv6", internal: false },
|
|
16
|
+
{ address: "127.0.0.1", family: "IPv4", internal: true },
|
|
17
|
+
],
|
|
18
|
+
Tailscale: [{ address: "100.96.0.5", family: "IPv4", internal: false }],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("service identity", () => {
|
|
22
|
+
it("uses the package name without a scope", () => {
|
|
23
|
+
expect(serviceName("@veducio/billing", "app")).toBe("billing");
|
|
24
|
+
expect(serviceName(undefined, "notes")).toBe("notes");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("prefers the configured name over package.json", () => {
|
|
28
|
+
const root = mkdtempSync(path.join(tmpdir(), "keja-identity-"));
|
|
29
|
+
writeFileSync(
|
|
30
|
+
path.join(root, "package.json"),
|
|
31
|
+
JSON.stringify({ name: "@veducio/billing" }),
|
|
32
|
+
);
|
|
33
|
+
expect(readServiceName(root, null)).toBe("billing");
|
|
34
|
+
expect(readServiceName(root, "payments")).toBe("payments");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("lists reachable addresses for both API surfaces", () => {
|
|
38
|
+
expect(accessibleHosts("127.0.0.1", lan)).toEqual(["127.0.0.1"]);
|
|
39
|
+
expect(accessibleHosts("0.0.0.0", lan)).toEqual([
|
|
40
|
+
"192.168.1.10",
|
|
41
|
+
"100.96.0.5",
|
|
42
|
+
]);
|
|
43
|
+
expect(
|
|
44
|
+
buildIdentity({
|
|
45
|
+
name: "billing",
|
|
46
|
+
bindHost: "0.0.0.0",
|
|
47
|
+
publicPort: 3000,
|
|
48
|
+
servicePort: 3001,
|
|
49
|
+
interfaces: lan,
|
|
50
|
+
}),
|
|
51
|
+
).toEqual({
|
|
52
|
+
name: "billing",
|
|
53
|
+
public: ["http://192.168.1.10:3000", "http://100.96.0.5:3000"],
|
|
54
|
+
service: ["http://192.168.1.10:3001", "http://100.96.0.5:3001"],
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export type NetworkAddress = {
|
|
5
|
+
address: string;
|
|
6
|
+
family: string | number;
|
|
7
|
+
internal: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ServiceIdentity = {
|
|
11
|
+
name: string;
|
|
12
|
+
public: string[];
|
|
13
|
+
service: string[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function serviceName(
|
|
17
|
+
packageName: string | undefined,
|
|
18
|
+
directoryName: string,
|
|
19
|
+
) {
|
|
20
|
+
const trimmed = packageName?.trim() ?? "";
|
|
21
|
+
if (!trimmed) {
|
|
22
|
+
return directoryName;
|
|
23
|
+
}
|
|
24
|
+
const slash = trimmed.lastIndexOf("/");
|
|
25
|
+
return slash === -1 ? trimmed : trimmed.slice(slash + 1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readServiceName(root: string, configured: string | null) {
|
|
29
|
+
if (configured) {
|
|
30
|
+
return configured;
|
|
31
|
+
}
|
|
32
|
+
return serviceName(readPackage(root).name, path.basename(root));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readPackageVersion(root: string) {
|
|
36
|
+
const version = readPackage(root).version?.trim();
|
|
37
|
+
return version ? version : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function accessibleHosts(
|
|
41
|
+
bindHost: string,
|
|
42
|
+
interfaces: Record<string, readonly NetworkAddress[] | undefined>,
|
|
43
|
+
) {
|
|
44
|
+
const bind = bindHost.trim();
|
|
45
|
+
if (bind !== "0.0.0.0" && bind !== "::" && bind !== "[::]") {
|
|
46
|
+
return [bind];
|
|
47
|
+
}
|
|
48
|
+
const found: string[] = [];
|
|
49
|
+
for (const entries of Object.values(interfaces)) {
|
|
50
|
+
for (const entry of entries ?? []) {
|
|
51
|
+
if (!usableAddress(entry) || found.includes(entry.address)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
found.push(entry.address);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const ipv4 = found.filter((address) => !address.includes(":"));
|
|
58
|
+
const chosen = ipv4.length > 0 ? ipv4 : found;
|
|
59
|
+
return chosen.length > 0 ? chosen : ["127.0.0.1"];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function buildIdentity(input: {
|
|
63
|
+
name: string;
|
|
64
|
+
bindHost: string;
|
|
65
|
+
publicPort: number;
|
|
66
|
+
servicePort: number;
|
|
67
|
+
interfaces: Record<string, readonly NetworkAddress[] | undefined>;
|
|
68
|
+
}): ServiceIdentity {
|
|
69
|
+
const hosts = accessibleHosts(input.bindHost, input.interfaces);
|
|
70
|
+
return {
|
|
71
|
+
name: input.name,
|
|
72
|
+
public: hosts.map((host) => endpoint(host, input.publicPort)),
|
|
73
|
+
service: hosts.map((host) => endpoint(host, input.servicePort)),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function usableAddress(entry: NetworkAddress) {
|
|
78
|
+
if (entry.internal || entry.address.includes("%")) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
if (
|
|
82
|
+
entry.address.startsWith("169.254.") ||
|
|
83
|
+
entry.address.startsWith("fe80:")
|
|
84
|
+
) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return (
|
|
88
|
+
entry.family === 4 ||
|
|
89
|
+
entry.family === "IPv4" ||
|
|
90
|
+
entry.family === 6 ||
|
|
91
|
+
entry.family === "IPv6"
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function endpoint(host: string, port: number) {
|
|
96
|
+
const formatted =
|
|
97
|
+
host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
98
|
+
return `http://${formatted}:${port}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readPackage(root: string) {
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(
|
|
104
|
+
readFileSync(path.join(root, "package.json"), "utf8"),
|
|
105
|
+
) as {
|
|
106
|
+
name?: unknown;
|
|
107
|
+
version?: unknown;
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
name: typeof parsed.name === "string" ? parsed.name : undefined,
|
|
111
|
+
version: typeof parsed.version === "string" ? parsed.version : undefined,
|
|
112
|
+
};
|
|
113
|
+
} catch {
|
|
114
|
+
return { name: undefined, version: undefined };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createHttpApp } from "../http/create-app.ts";
|
|
3
|
+
import { defineVersions } from "../versioning/define.ts";
|
|
4
|
+
|
|
5
|
+
describe("createServer import", () => {
|
|
6
|
+
it("does not listen when the http app is created", async () => {
|
|
7
|
+
const app = createHttpApp({
|
|
8
|
+
title: "Public",
|
|
9
|
+
versions: defineVersions({ v1: { releasedAt: "2020-01-15" } }).versions,
|
|
10
|
+
routes: [],
|
|
11
|
+
});
|
|
12
|
+
const response = await app.request("/_health");
|
|
13
|
+
expect(response.status).toBe(200);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { networkInterfaces } from "node:os";
|
|
2
|
+
import { setArgus } from "../argus/call.ts";
|
|
3
|
+
import { startArgusHeartbeat } from "../argus/heartbeat.ts";
|
|
4
|
+
import { argusRegistrations, publishArgus } from "../argus/register.ts";
|
|
5
|
+
import { connectDatabase } from "../db/postgres.ts";
|
|
6
|
+
import { readOpenApiServers, readRequiredEnv } from "../env/required-env.ts";
|
|
7
|
+
import { projectError } from "../errors/framework-errors.ts";
|
|
8
|
+
import { createHttpApp } from "../http/create-app.ts";
|
|
9
|
+
import { type LoadedProject, loadProject } from "../project/load.ts";
|
|
10
|
+
import { formatSelfTest, runSelfTest } from "../self-test/run.ts";
|
|
11
|
+
import { connectVault, setVault } from "../vault/client.ts";
|
|
12
|
+
import {
|
|
13
|
+
buildIdentity,
|
|
14
|
+
readPackageVersion,
|
|
15
|
+
readServiceName,
|
|
16
|
+
} from "./identity.ts";
|
|
17
|
+
import { swapProjectHandlers } from "./swap.ts";
|
|
18
|
+
|
|
19
|
+
export type { AccessIdentity } from "../access/cloudflare.ts";
|
|
20
|
+
export { cloudflareAccess, verifyAccessJwt } from "../access/cloudflare.ts";
|
|
21
|
+
export { callService, ServiceCallError } from "../argus/call.ts";
|
|
22
|
+
export { DBCall } from "../db/client.ts";
|
|
23
|
+
export {
|
|
24
|
+
DependencyNotMet,
|
|
25
|
+
frameworkErrors,
|
|
26
|
+
} from "../errors/framework-errors.ts";
|
|
27
|
+
export type { Grant } from "../grant/grant.ts";
|
|
28
|
+
export { grant, verifyGrant } from "../grant/grant.ts";
|
|
29
|
+
export { defineConfig } from "../project/config.ts";
|
|
30
|
+
export { defineRepositories } from "../repository/define.ts";
|
|
31
|
+
export { repository } from "../repository/runtime.ts";
|
|
32
|
+
export { defineRoute, response } from "../route/define.ts";
|
|
33
|
+
export { defineMiddleware } from "../route/middleware.ts";
|
|
34
|
+
export { defineSeeder } from "../seed/define.ts";
|
|
35
|
+
export type {
|
|
36
|
+
CreateObjectRowOptions,
|
|
37
|
+
ObjectRowMeta,
|
|
38
|
+
ParsedTypeId,
|
|
39
|
+
} from "../typeid/id.ts";
|
|
40
|
+
export {
|
|
41
|
+
assertObjectType,
|
|
42
|
+
createObjectRow,
|
|
43
|
+
createTypeId,
|
|
44
|
+
idPrefixFromObjectType,
|
|
45
|
+
isObjectType,
|
|
46
|
+
isTypeId,
|
|
47
|
+
objectTypeFromRoute,
|
|
48
|
+
parseTypeId,
|
|
49
|
+
TypeIdError,
|
|
50
|
+
} from "../typeid/id.ts";
|
|
51
|
+
export type { OwnedObject } from "../typeid/owned.ts";
|
|
52
|
+
export { assertOwnedObject } from "../typeid/owned.ts";
|
|
53
|
+
export { createVault, VaultError, vaultSecret } from "../vault/client.ts";
|
|
54
|
+
export { defineVersions } from "../versioning/define.ts";
|
|
55
|
+
|
|
56
|
+
export async function listen(
|
|
57
|
+
root = process.cwd(),
|
|
58
|
+
options?: { generation?: string },
|
|
59
|
+
) {
|
|
60
|
+
const project = await loadProject(root, options);
|
|
61
|
+
const result = runSelfTest();
|
|
62
|
+
if (!result.ok) {
|
|
63
|
+
console.error(formatSelfTest(result, process.stderr.isTTY === true));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
console.log(formatSelfTest(result, process.stdout.isTTY === true));
|
|
67
|
+
|
|
68
|
+
const env = readRequiredEnv(Bun.env);
|
|
69
|
+
const openApiServers = readOpenApiServers(Bun.env);
|
|
70
|
+
if (project.database.enabled) {
|
|
71
|
+
await connectDatabase(Bun.env.DATABASE_URL ?? "");
|
|
72
|
+
}
|
|
73
|
+
if (project.vault.enabled) {
|
|
74
|
+
await connectVault(project.vault);
|
|
75
|
+
} else {
|
|
76
|
+
setVault(null);
|
|
77
|
+
}
|
|
78
|
+
const mounted = mountProject(root, project, env, openApiServers);
|
|
79
|
+
const identity = mounted.identity;
|
|
80
|
+
if (project.argus.enabled) {
|
|
81
|
+
const errors = await publishArgus({
|
|
82
|
+
url: project.argus.url,
|
|
83
|
+
username: project.argus.username,
|
|
84
|
+
password: project.argus.password,
|
|
85
|
+
registrations: argusRegistrations({
|
|
86
|
+
identity,
|
|
87
|
+
argus: project.argus,
|
|
88
|
+
version: readPackageVersion(root),
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
if (errors.length > 0) {
|
|
92
|
+
throw projectError("argusInit", errors.join("; "));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const publicServer = Bun.serve({
|
|
96
|
+
hostname: env.host,
|
|
97
|
+
port: env.httpPort,
|
|
98
|
+
fetch(request) {
|
|
99
|
+
return mounted.publicApp.fetch(request);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
const serviceServer = Bun.serve({
|
|
103
|
+
hostname: env.host,
|
|
104
|
+
port: env.httpServicePort,
|
|
105
|
+
fetch(request) {
|
|
106
|
+
return mounted.serviceApp.fetch(request);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
setArgus(project.argus.enabled ? project.argus : null);
|
|
110
|
+
if (project.argus.enabled) {
|
|
111
|
+
startArgusHeartbeat({
|
|
112
|
+
argus: project.argus,
|
|
113
|
+
identity,
|
|
114
|
+
version: readPackageVersion(root),
|
|
115
|
+
lead: false,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
publicServer,
|
|
120
|
+
serviceServer,
|
|
121
|
+
paths: project.paths,
|
|
122
|
+
identity,
|
|
123
|
+
argus: project.argus,
|
|
124
|
+
vault: project.vault,
|
|
125
|
+
database: project.database.enabled ? { connected: true as const } : null,
|
|
126
|
+
reload(next: LoadedProject) {
|
|
127
|
+
swapProjectHandlers(
|
|
128
|
+
{ publicServer, serviceServer },
|
|
129
|
+
mountProject(root, next, env, openApiServers),
|
|
130
|
+
);
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function mountProject(
|
|
136
|
+
root: string,
|
|
137
|
+
project: LoadedProject,
|
|
138
|
+
env: { host: string; httpPort: number; httpServicePort: number },
|
|
139
|
+
openApiServers: {
|
|
140
|
+
openapiServer: string | null;
|
|
141
|
+
openapiServiceServer: string | null;
|
|
142
|
+
},
|
|
143
|
+
) {
|
|
144
|
+
const identity = buildIdentity({
|
|
145
|
+
name: readServiceName(root, project.name),
|
|
146
|
+
bindHost: env.host,
|
|
147
|
+
publicPort: env.httpPort,
|
|
148
|
+
servicePort: env.httpServicePort,
|
|
149
|
+
interfaces: networkInterfaces(),
|
|
150
|
+
});
|
|
151
|
+
return {
|
|
152
|
+
identity,
|
|
153
|
+
publicApp: createHttpApp({
|
|
154
|
+
title: "Public",
|
|
155
|
+
versions: project.versions,
|
|
156
|
+
routes: project.publicRoutes,
|
|
157
|
+
middleware: project.publicMiddleware,
|
|
158
|
+
server: openApiServers.openapiServer,
|
|
159
|
+
identity,
|
|
160
|
+
}),
|
|
161
|
+
serviceApp: createHttpApp({
|
|
162
|
+
title: "Service",
|
|
163
|
+
versions: project.versions,
|
|
164
|
+
routes: project.serviceRoutes,
|
|
165
|
+
middleware: project.serviceMiddleware,
|
|
166
|
+
server: openApiServers.openapiServiceServer,
|
|
167
|
+
identity,
|
|
168
|
+
postOnly: true,
|
|
169
|
+
}),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createHttpApp } from "../http/create-app.ts";
|
|
4
|
+
import { defineRoute, response } from "../route/define.ts";
|
|
5
|
+
import { defineVersions } from "../versioning/define.ts";
|
|
6
|
+
import { swapProjectHandlers } from "./swap.ts";
|
|
7
|
+
|
|
8
|
+
const versions = defineVersions({
|
|
9
|
+
v1: { releasedAt: "2026-01-01" },
|
|
10
|
+
v2: { releasedAt: "2026-06-01" },
|
|
11
|
+
}).versions;
|
|
12
|
+
|
|
13
|
+
describe("project handler swap", () => {
|
|
14
|
+
it("reloads openapi and docs on both servers", async () => {
|
|
15
|
+
const first = apps("/notes", "v1");
|
|
16
|
+
const second = apps("/posts", "v2");
|
|
17
|
+
const fetches: Array<(request: Request) => Response | Promise<Response>> =
|
|
18
|
+
[];
|
|
19
|
+
const server = () => ({
|
|
20
|
+
reload(options: {
|
|
21
|
+
fetch: (request: Request) => Response | Promise<Response>;
|
|
22
|
+
}) {
|
|
23
|
+
fetches.push(options.fetch);
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
swapProjectHandlers(
|
|
27
|
+
{ publicServer: server(), serviceServer: server() },
|
|
28
|
+
second,
|
|
29
|
+
);
|
|
30
|
+
expect(
|
|
31
|
+
await (await first.publicApp.request("/_openapi.json")).json(),
|
|
32
|
+
).toMatchObject({
|
|
33
|
+
paths: { "/v1/notes": { get: expect.any(Object) } },
|
|
34
|
+
});
|
|
35
|
+
const publicSpec = await fetches[0]?.(
|
|
36
|
+
new Request("http://localhost/_openapi.json"),
|
|
37
|
+
);
|
|
38
|
+
const serviceSpec = await fetches[1]?.(
|
|
39
|
+
new Request("http://localhost/_openapi.json"),
|
|
40
|
+
);
|
|
41
|
+
const publicDocs = await fetches[0]?.(new Request("http://localhost/docs"));
|
|
42
|
+
expect(publicSpec?.status).toBe(200);
|
|
43
|
+
expect(await publicSpec?.json()).toMatchObject({
|
|
44
|
+
paths: { "/v2/posts": { get: expect.any(Object) } },
|
|
45
|
+
});
|
|
46
|
+
expect(serviceSpec?.status).toBe(200);
|
|
47
|
+
expect(await serviceSpec?.json()).toMatchObject({
|
|
48
|
+
paths: { "/v2/posts": { post: expect.any(Object) } },
|
|
49
|
+
});
|
|
50
|
+
expect(publicDocs?.status).toBe(200);
|
|
51
|
+
expect(await publicDocs?.text()).toContain("/_openapi.json");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function apps(routePath: string, version: "v1" | "v2") {
|
|
56
|
+
const routes = [
|
|
57
|
+
defineRoute({
|
|
58
|
+
method: "get",
|
|
59
|
+
path: routePath,
|
|
60
|
+
version,
|
|
61
|
+
responses: {
|
|
62
|
+
Ok: response({ code: 200, json: z.object({ ok: z.literal(true) }) }),
|
|
63
|
+
},
|
|
64
|
+
handler: ({ respond }) => respond.Ok({ ok: true }),
|
|
65
|
+
}),
|
|
66
|
+
defineRoute({
|
|
67
|
+
method: "post",
|
|
68
|
+
path: routePath,
|
|
69
|
+
version,
|
|
70
|
+
responses: {
|
|
71
|
+
Ok: response({ code: 200, json: z.object({ ok: z.literal(true) }) }),
|
|
72
|
+
},
|
|
73
|
+
handler: ({ respond }) => respond.Ok({ ok: true }),
|
|
74
|
+
}),
|
|
75
|
+
];
|
|
76
|
+
return {
|
|
77
|
+
publicApp: createHttpApp({
|
|
78
|
+
title: "Public",
|
|
79
|
+
versions,
|
|
80
|
+
routes: routes.filter((route) => route.method === "get"),
|
|
81
|
+
}),
|
|
82
|
+
serviceApp: createHttpApp({
|
|
83
|
+
title: "Service",
|
|
84
|
+
versions,
|
|
85
|
+
routes: routes.filter((route) => route.method === "post"),
|
|
86
|
+
postOnly: true,
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function swapProjectHandlers(
|
|
2
|
+
servers: {
|
|
3
|
+
publicServer: {
|
|
4
|
+
reload: (options: {
|
|
5
|
+
fetch: (request: Request) => Response | Promise<Response>;
|
|
6
|
+
}) => void;
|
|
7
|
+
};
|
|
8
|
+
serviceServer: {
|
|
9
|
+
reload: (options: {
|
|
10
|
+
fetch: (request: Request) => Response | Promise<Response>;
|
|
11
|
+
}) => void;
|
|
12
|
+
};
|
|
13
|
+
},
|
|
14
|
+
apps: {
|
|
15
|
+
publicApp: {
|
|
16
|
+
fetch: (request: Request) => Response | Promise<Response>;
|
|
17
|
+
};
|
|
18
|
+
serviceApp: {
|
|
19
|
+
fetch: (request: Request) => Response | Promise<Response>;
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
) {
|
|
23
|
+
servers.publicServer.reload({
|
|
24
|
+
fetch: (request) => apps.publicApp.fetch(request),
|
|
25
|
+
});
|
|
26
|
+
servers.serviceServer.reload({
|
|
27
|
+
fetch: (request) => apps.serviceApp.fetch(request),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createObjectRow,
|
|
4
|
+
createTypeId,
|
|
5
|
+
idPrefixFromObjectType,
|
|
6
|
+
isObjectType,
|
|
7
|
+
isTypeId,
|
|
8
|
+
objectTypeFromRoute,
|
|
9
|
+
parseTypeId,
|
|
10
|
+
} from "./id.ts";
|
|
11
|
+
|
|
12
|
+
describe("typeids", () => {
|
|
13
|
+
it("creates a prefixed typeid and reads it back", () => {
|
|
14
|
+
const id = createTypeId("user");
|
|
15
|
+
expect(isTypeId(id, "user")).toBe(true);
|
|
16
|
+
expect(parseTypeId(id)).toEqual({
|
|
17
|
+
prefix: "user",
|
|
18
|
+
suffix: id.slice("user_".length),
|
|
19
|
+
});
|
|
20
|
+
expect(id.slice("user_".length)).toHaveLength(26);
|
|
21
|
+
expect(parseTypeId("User_abc")).toBeNull();
|
|
22
|
+
expect(() => createTypeId("User")).toThrow("invalid type prefix: User");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("derives an object type from a route and builds an OwnedObjects row", () => {
|
|
26
|
+
expect(objectTypeFromRoute(2, "/core/users")).toBe("v2.core.users");
|
|
27
|
+
expect(idPrefixFromObjectType("v2.core.users")).toBe("users");
|
|
28
|
+
expect(isObjectType("v2.core.users")).toBe(true);
|
|
29
|
+
expect(isObjectType("users")).toBe(false);
|
|
30
|
+
const row = createObjectRow("v2.core.users", { tenant: "acme" });
|
|
31
|
+
expect(row._object).toBe("v2.core.users");
|
|
32
|
+
expect(row._tenant).toBe("acme");
|
|
33
|
+
expect(row._archived).toBe(false);
|
|
34
|
+
expect(row._createdAt).toEqual(row._updatedAt);
|
|
35
|
+
expect(isTypeId(row._id, "users")).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects a route that cannot become an object type", () => {
|
|
39
|
+
expect(() => objectTypeFromRoute(0, "/users")).toThrow(
|
|
40
|
+
"invalid API version: 0",
|
|
41
|
+
);
|
|
42
|
+
expect(() => objectTypeFromRoute(1, "/users/:id")).toThrow(
|
|
43
|
+
'invalid route segment ":id" in /users/:id',
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
});
|