@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.
@@ -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();
@@ -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
- if (environment === "local") return "localhost";
207
- return await createTunnel(type, { app: this.app, environment });
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,
@@ -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("leading-flight-guidance", "myapp")).toBe("com.leadingflightguidance.myapp");
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
- await createSshTunnel({
15
- localHost: "0.0.0.0",
16
- localPort: port,
17
- srcHost: "0.0.0.0",
18
- srcPort: port,
19
- dstHost: `${service}-0.${service}-svc.${app.name}-${environment}.svc.cluster.local`,
20
- dstPort: service === "postgres" ? 5432 : 6379,
21
- sshOptions: {
22
- host: `${app.name}-${environment}.${serveDomain}`,
23
- port: process.env.SSH_TUNNEL_PORT ? parseInt(process.env.SSH_TUNNEL_PORT) : 32767,
24
- username: process.env.SSH_TUNNEL_USERNAME ?? "root",
25
- password: process.env.SSH_TUNNEL_PASSWORD ?? repoName,
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-rc.0",
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-rc.0",
50
+ "akanjs": "3.0.2",
51
51
  "chalk": "^5.6.2",
52
52
  "commander": "^14.0.3",
53
53
  "dayjs": "^1.11.20",
@@ -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
+ });