@akanjs/devkit 3.0.2-rc.0 → 3.0.2
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/akanApp/akanApp.host.test.ts +62 -0
- package/akanApp/akanApp.host.ts +13 -2
- package/akanApp/devHostPolicy.test.ts +14 -0
- package/akanApp/devHostPolicy.ts +11 -0
- package/akanConfig/akanConfig.test.ts +1 -1
- package/createTunnel.test.ts +51 -0
- package/createTunnel.ts +32 -14
- package/package.json +2 -2
- package/workspaceLayout.test.ts +54 -0
|
@@ -19,6 +19,68 @@ describe("readProcessRssBytes", () => {
|
|
|
19
19
|
});
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
describe("Redis tunnels during dev startup", () => {
|
|
23
|
+
isolateEnv({ AKAN_PUBLIC_REPO_NAME: "repo", AKAN_PUBLIC_SERVE_DOMAIN: "localhost", AKAN_PUBLIC_ENV: "testing" });
|
|
24
|
+
const track = tempRoots();
|
|
25
|
+
|
|
26
|
+
test.each(["single", "multiple", "cluster", undefined, "local"] as const)(
|
|
27
|
+
"%s mode preserves its Redis startup requirement",
|
|
28
|
+
async (mode) => {
|
|
29
|
+
const { root, app } = track(await createTempApp("demo"));
|
|
30
|
+
await writeText(
|
|
31
|
+
path.join(root, "apps/demo/main.ts"),
|
|
32
|
+
'process.send?.({ type: "backend-ready", pid: process.pid });\nsetInterval(() => undefined, 1_000);\n',
|
|
33
|
+
);
|
|
34
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
35
|
+
const port = listener.port;
|
|
36
|
+
listener.stop(true);
|
|
37
|
+
process.env.SSH_TUNNEL_PORT = String(port);
|
|
38
|
+
if (mode === "local") process.env.AKAN_PUBLIC_ENV = "local";
|
|
39
|
+
const create = IncrementalBuilderHost.create;
|
|
40
|
+
IncrementalBuilderHost.create = async () =>
|
|
41
|
+
({
|
|
42
|
+
status: "ready",
|
|
43
|
+
patcherOff: false,
|
|
44
|
+
start: ({ onReady }: { onReady?: () => void }) => onReady?.(),
|
|
45
|
+
send: () => true,
|
|
46
|
+
stop: () => undefined,
|
|
47
|
+
}) as unknown as IncrementalBuilderHost;
|
|
48
|
+
const ready = Promise.withResolvers<void>();
|
|
49
|
+
const env = { ...process.env } as Record<string, string>;
|
|
50
|
+
delete env.AKAN_DATABASE_MODE;
|
|
51
|
+
if (mode !== undefined && mode !== "local") env.AKAN_DATABASE_MODE = mode;
|
|
52
|
+
const host = new AkanAppHost(app, {
|
|
53
|
+
env,
|
|
54
|
+
onDevEvent: (event) => {
|
|
55
|
+
if ("state" in event && event.state === "ready") ready.resolve();
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
59
|
+
try {
|
|
60
|
+
if (mode === "single" || mode === "local") {
|
|
61
|
+
await host.start();
|
|
62
|
+
await Promise.race([
|
|
63
|
+
ready.promise,
|
|
64
|
+
new Promise<never>((_, reject) => {
|
|
65
|
+
deadline = setTimeout(() => reject(new Error("backend-ready IPC timed out")), 5_000);
|
|
66
|
+
}),
|
|
67
|
+
]);
|
|
68
|
+
expect(host.env.REDIS_HOST).toBe(mode === "local" ? "localhost" : "redis-tunnel-unavailable.invalid");
|
|
69
|
+
if (mode === "single") expect(host.env.REDIS_HOST).not.toBe("localhost");
|
|
70
|
+
} else {
|
|
71
|
+
await expect(host.start()).rejects.toThrow(
|
|
72
|
+
new RegExp(`redis.*demo-testing\\.localhost:${port}.*ECONNREFUSED`),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
clearTimeout(deadline);
|
|
77
|
+
await host.stop();
|
|
78
|
+
IncrementalBuilderHost.create = create;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
22
84
|
describe("a gateway whose replica crash-loops", () => {
|
|
23
85
|
isolateEnv({ AKAN_PUBLIC_REPO_NAME: "repo", AKAN_PUBLIC_SERVE_DOMAIN: "localhost", AKAN_PUBLIC_ENV: "local" });
|
|
24
86
|
const track = tempRoots();
|
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
mergeBackendRestartReasons,
|
|
33
33
|
mergeInvalidateMessages,
|
|
34
34
|
normalizeBackendReportedGeneration,
|
|
35
|
+
redisTunnelPolicy,
|
|
35
36
|
resolveIdleSuspendMs,
|
|
36
37
|
type SourceFingerprints,
|
|
37
38
|
shouldAbandonBackendRecovery,
|
|
@@ -46,6 +47,7 @@ import {
|
|
|
46
47
|
shouldRestartBuilderByDevPlan,
|
|
47
48
|
shouldRestartDevHostByDevPlan,
|
|
48
49
|
shouldWarnBuilderRssCeilingTight,
|
|
50
|
+
UNAVAILABLE_REDIS_HOST,
|
|
49
51
|
} from "./devHostPolicy";
|
|
50
52
|
|
|
51
53
|
const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route", "build-csr"]);
|
|
@@ -203,8 +205,17 @@ export class AkanAppHost {
|
|
|
203
205
|
|
|
204
206
|
async #prepareDatabase(type: "redis") {
|
|
205
207
|
const environment = WorkspaceExecutor.getBaseDevEnv().env;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
+
const policy = redisTunnelPolicy({ environment, databaseMode: this.env.AKAN_DATABASE_MODE });
|
|
209
|
+
if (policy === "none") return "localhost";
|
|
210
|
+
if (policy === "required") return await createTunnel(type, { app: this.app, environment });
|
|
211
|
+
try {
|
|
212
|
+
return await createTunnel(type, { app: this.app, environment });
|
|
213
|
+
} catch (error) {
|
|
214
|
+
this.logger.warn(
|
|
215
|
+
`${error instanceof Error ? error.message : String(error)} - continuing without Redis: the single database mode does not use it unless an app adaptor does`,
|
|
216
|
+
);
|
|
217
|
+
return UNAVAILABLE_REDIS_HOST;
|
|
218
|
+
}
|
|
208
219
|
}
|
|
209
220
|
#startBackend(startStatus: { generation?: number; files: string[] } | null = null) {
|
|
210
221
|
if (this.#stopping) return;
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
mergeBackendRestartReasons,
|
|
17
17
|
mergeInvalidateMessages,
|
|
18
18
|
normalizeBackendReportedGeneration,
|
|
19
|
+
redisTunnelPolicy,
|
|
19
20
|
resolveIdleSuspendMs,
|
|
20
21
|
shouldAbandonBackendRecovery,
|
|
21
22
|
shouldHoldForReturningBuilder,
|
|
@@ -31,6 +32,19 @@ import {
|
|
|
31
32
|
shouldWarnBuilderRssCeilingTight,
|
|
32
33
|
} from "./devHostPolicy";
|
|
33
34
|
|
|
35
|
+
describe("redisTunnelPolicy", () => {
|
|
36
|
+
test.each([
|
|
37
|
+
["local", "single", "none"],
|
|
38
|
+
["local", "multiple", "none"],
|
|
39
|
+
["testing", "single", "optional"],
|
|
40
|
+
["testing", "multiple", "required"],
|
|
41
|
+
["testing", "cluster", "required"],
|
|
42
|
+
["testing", undefined, "required"],
|
|
43
|
+
] as const)("%s with %s Redis is %s", (environment, databaseMode, expected) => {
|
|
44
|
+
expect(redisTunnelPolicy({ environment, databaseMode })).toBe(expected);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
34
48
|
const status = (phase: BuildPhase, generation: number, ok: boolean): DevBuildStatus => ({
|
|
35
49
|
generation,
|
|
36
50
|
phase,
|
package/akanApp/devHostPolicy.ts
CHANGED
|
@@ -9,6 +9,17 @@ import type {
|
|
|
9
9
|
} from "akanjs/server";
|
|
10
10
|
import type { IncrementalBuilderStatus } from "../incrementalBuilder";
|
|
11
11
|
|
|
12
|
+
export const UNAVAILABLE_REDIS_HOST = "redis-tunnel-unavailable.invalid";
|
|
13
|
+
|
|
14
|
+
export const redisTunnelPolicy = ({
|
|
15
|
+
environment,
|
|
16
|
+
databaseMode,
|
|
17
|
+
}: {
|
|
18
|
+
environment: string;
|
|
19
|
+
databaseMode?: string;
|
|
20
|
+
}): "none" | "optional" | "required" =>
|
|
21
|
+
environment === "local" ? "none" : databaseMode === "single" ? "optional" : "required";
|
|
22
|
+
|
|
12
23
|
const BACKEND_RECOVERY_MAX_ATTEMPTS = 5;
|
|
13
24
|
|
|
14
25
|
// A page load asks for several routes, but a builder that never returns must not grow the hold queue unbounded.
|
|
@@ -696,7 +696,7 @@ describe("AkanAppConfig", () => {
|
|
|
696
696
|
|
|
697
697
|
describe("deriveDefaultAppId", () => {
|
|
698
698
|
test("sanitizes org/app names into a valid reverse-DNS bundle id", () => {
|
|
699
|
-
expect(deriveDefaultAppId("
|
|
699
|
+
expect(deriveDefaultAppId("my-org", "myapp")).toBe("com.myorg.myapp");
|
|
700
700
|
expect(deriveDefaultAppId("Acme Corp", "Store")).toBe("com.acmecorp.store");
|
|
701
701
|
// Empty org and digit-leading segments stay valid package identifiers.
|
|
702
702
|
expect(deriveDefaultAppId("", "app")).toBe("com.app.app");
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { Client } from "ssh2";
|
|
3
|
+
import { createTunnel } from "./createTunnel";
|
|
4
|
+
import { createTempApp, isolateEnv, tempRoots } from "./testHelpers";
|
|
5
|
+
|
|
6
|
+
describe("createTunnel", () => {
|
|
7
|
+
isolateEnv({ AKAN_PUBLIC_REPO_NAME: "repo", AKAN_PUBLIC_SERVE_DOMAIN: "localhost", AKAN_PUBLIC_ENV: "testing" });
|
|
8
|
+
const track = tempRoots();
|
|
9
|
+
|
|
10
|
+
test("describes a refused Redis SSH connection and preserves its cause", async () => {
|
|
11
|
+
const { app } = track(await createTempApp("demo"));
|
|
12
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
13
|
+
const port = listener.port;
|
|
14
|
+
listener.stop(true);
|
|
15
|
+
process.env.SSH_TUNNEL_PORT = String(port);
|
|
16
|
+
|
|
17
|
+
let originalError: Error | undefined;
|
|
18
|
+
const connect = Client.prototype.connect;
|
|
19
|
+
const observeConnect = spyOn(Client.prototype, "connect").mockImplementation(function (this: Client, options) {
|
|
20
|
+
this.once("error", (error: Error) => {
|
|
21
|
+
originalError = error;
|
|
22
|
+
});
|
|
23
|
+
return connect.call(this, options);
|
|
24
|
+
});
|
|
25
|
+
let failure: unknown;
|
|
26
|
+
try {
|
|
27
|
+
await createTunnel("redis", { app, environment: "testing" });
|
|
28
|
+
} catch (error) {
|
|
29
|
+
failure = error;
|
|
30
|
+
} finally {
|
|
31
|
+
observeConnect.mockRestore();
|
|
32
|
+
}
|
|
33
|
+
expect(failure).toBeInstanceOf(Error);
|
|
34
|
+
if (!(failure instanceof Error)) throw new Error("expected tunnel failure");
|
|
35
|
+
expect(failure.message).toContain("redis");
|
|
36
|
+
expect(failure.message).toContain(`demo-testing.localhost:${port}`);
|
|
37
|
+
expect(failure.message).toContain("redis-0.redis-svc.demo-testing.svc.cluster.local:6379");
|
|
38
|
+
expect(failure.message).toContain("ECONNREFUSED");
|
|
39
|
+
expect(failure.cause).toBeInstanceOf(Error);
|
|
40
|
+
expect(failure.cause).toBe(originalError);
|
|
41
|
+
expect(failure.cause).not.toBe(failure);
|
|
42
|
+
if (!(failure.cause instanceof Error)) throw new Error("expected original SSH error");
|
|
43
|
+
const reasons = failure.cause instanceof AggregateError ? failure.cause.errors : [failure.cause];
|
|
44
|
+
expect(reasons.length).toBeGreaterThan(0);
|
|
45
|
+
for (const reason of reasons) {
|
|
46
|
+
expect(reason).toBeInstanceOf(Error);
|
|
47
|
+
expect(reason.message).toContain("ECONNREFUSED");
|
|
48
|
+
expect(failure.message).toContain(reason.message);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
package/createTunnel.ts
CHANGED
|
@@ -11,19 +11,37 @@ export const createTunnel = async (
|
|
|
11
11
|
{ app, environment, port = service === "postgres" ? 5432 : 6379 }: TunnelOption,
|
|
12
12
|
) => {
|
|
13
13
|
const { serveDomain, repoName } = WorkspaceExecutor.getBaseDevEnv();
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
14
|
+
const host = `${app.name}-${environment}.${serveDomain}`;
|
|
15
|
+
const sshPort = process.env.SSH_TUNNEL_PORT ? parseInt(process.env.SSH_TUNNEL_PORT) : 32767;
|
|
16
|
+
const dstHost = `${service}-0.${service}-svc.${app.name}-${environment}.svc.cluster.local`;
|
|
17
|
+
const dstPort = service === "postgres" ? 5432 : 6379;
|
|
18
|
+
try {
|
|
19
|
+
await createSshTunnel({
|
|
20
|
+
localHost: "0.0.0.0",
|
|
21
|
+
localPort: port,
|
|
22
|
+
srcHost: "0.0.0.0",
|
|
23
|
+
srcPort: port,
|
|
24
|
+
dstHost,
|
|
25
|
+
dstPort,
|
|
26
|
+
sshOptions: {
|
|
27
|
+
//? RFC 6761 makes *.localhost loopback, but only macOS resolves it; glibc without systemd-resolved and Windows answer ENOTFOUND
|
|
28
|
+
host: host.endsWith(".localhost") ? "localhost" : host,
|
|
29
|
+
port: sshPort,
|
|
30
|
+
username: process.env.SSH_TUNNEL_USERNAME ?? "root",
|
|
31
|
+
password: process.env.SSH_TUNNEL_PASSWORD ?? repoName,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const reasons =
|
|
36
|
+
error instanceof AggregateError
|
|
37
|
+
? error.errors.map((reason) => (reason instanceof Error ? reason.message : String(reason))).join("; ")
|
|
38
|
+
: error instanceof Error
|
|
39
|
+
? error.message
|
|
40
|
+
: String(error);
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Could not open the ${service} tunnel for ${app.name}-${environment}: SSH ${host}:${sshPort} -> ${dstHost}:${dstPort} failed (${reasons})`,
|
|
43
|
+
{ cause: error },
|
|
44
|
+
);
|
|
45
|
+
}
|
|
28
46
|
return `localhost:${port}`;
|
|
29
47
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.2
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"@earendil-works/pi-tui": "0.86.1",
|
|
48
48
|
"@inquirer/prompts": "^8.4.3",
|
|
49
49
|
"@tailwindcss/node": "^4.3.0",
|
|
50
|
-
"akanjs": "3.0.2
|
|
50
|
+
"akanjs": "3.0.2",
|
|
51
51
|
"chalk": "^5.6.2",
|
|
52
52
|
"commander": "^14.0.3",
|
|
53
53
|
"dayjs": "^1.11.20",
|
package/workspaceLayout.test.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
import {
|
|
3
5
|
appRootAllowedDirs,
|
|
4
6
|
appRootAllowedFiles,
|
|
@@ -114,3 +116,55 @@ describe("lib facet root allowlist", () => {
|
|
|
114
116
|
expect(isAllowedLibFacetRootFile("user.test.ts")).toBe(false);
|
|
115
117
|
});
|
|
116
118
|
});
|
|
119
|
+
|
|
120
|
+
const templatesDir = path.join(import.meta.dir, "..", "cli", "templates");
|
|
121
|
+
|
|
122
|
+
type TsConfig = { references?: { path: string }[] };
|
|
123
|
+
|
|
124
|
+
const loadRootTemplate = async (dir: string): Promise<TsConfig> =>
|
|
125
|
+
JSON.parse(await Bun.file(path.join(templatesDir, dir, "tsconfig.json.template")).text());
|
|
126
|
+
|
|
127
|
+
const templateDirsWithRootTsconfig = async (): Promise<string[]> => {
|
|
128
|
+
const entries = await readdir(templatesDir, { withFileTypes: true });
|
|
129
|
+
const dirs: string[] = [];
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
if (!entry.isDirectory()) continue;
|
|
132
|
+
if (await Bun.file(path.join(templatesDir, entry.name, "tsconfig.json.template")).exists()) dirs.push(entry.name);
|
|
133
|
+
}
|
|
134
|
+
return dirs.sort((a, b) => a.localeCompare(b));
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
describe("cli root tsconfig templates vs workspace scan allowlists", () => {
|
|
138
|
+
test("only the app and libRoot root templates may declare references", async () => {
|
|
139
|
+
const dirs = await templateDirsWithRootTsconfig();
|
|
140
|
+
for (const dir of ["app", "libRoot", "pkgRoot", "workspaceRoot"]) expect(dirs).toContain(dir);
|
|
141
|
+
for (const dir of dirs) {
|
|
142
|
+
const tsconfig = await loadRootTemplate(dir);
|
|
143
|
+
if (!tsconfig.references) continue;
|
|
144
|
+
expect(["app", "libRoot"], `${dir} template declares references`).toContain(dir);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("app template references stay inside appRootAllowedFiles", async () => {
|
|
149
|
+
const tsconfig = await loadRootTemplate("app");
|
|
150
|
+
for (const reference of tsconfig.references ?? []) {
|
|
151
|
+
const filename = path.basename(reference.path);
|
|
152
|
+
expect(appRootAllowedFiles.has(filename), `app template references "${reference.path}"`).toBe(true);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("libRoot template references stay inside libRootAllowedFiles", async () => {
|
|
157
|
+
const tsconfig = await loadRootTemplate("libRoot");
|
|
158
|
+
for (const reference of tsconfig.references ?? []) {
|
|
159
|
+
const filename = path.basename(reference.path);
|
|
160
|
+
expect(libRootAllowedFiles.has(filename), `libRoot template references "${reference.path}"`).toBe(true);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("pkgRoot and workspaceRoot templates declare no references", async () => {
|
|
165
|
+
for (const dir of ["pkgRoot", "workspaceRoot"]) {
|
|
166
|
+
const tsconfig = await loadRootTemplate(dir);
|
|
167
|
+
expect(tsconfig.references, `${dir} template must not declare references`).toBeUndefined();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|