@akanjs/devkit 3.0.2-rc.0 → 3.1.0-rc.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +46 -7
  2. package/akanApp/akanApp.host.test.ts +62 -0
  3. package/akanApp/akanApp.host.ts +16 -4
  4. package/akanApp/devHostPolicy.test.ts +14 -0
  5. package/akanApp/devHostPolicy.ts +11 -0
  6. package/akanConfig/akanConfig.test.ts +77 -7
  7. package/akanConfig/akanConfig.ts +111 -13
  8. package/akanConfig/akanWorkspaceConfig.test.ts +79 -0
  9. package/akanConfig/akanWorkspaceConfig.ts +63 -0
  10. package/akanConfig/index.ts +1 -0
  11. package/akanConfig/types.ts +23 -1
  12. package/akanContext.ts +1 -1
  13. package/applicationBuildRunner.test.ts +1 -1
  14. package/applicationBuildRunner.ts +73 -84
  15. package/artifact/implicitRootLayout.test.ts +6 -2
  16. package/artifact/implicitRootLayout.ts +12 -7
  17. package/biome.base.json +7 -2
  18. package/cloud/cloudApi.ts +82 -0
  19. package/cloud/globalConfig.ts +4 -0
  20. package/cloud/index.ts +1 -0
  21. package/cloud/updatePackUpload.test.ts +195 -0
  22. package/cloud/updatePackUpload.ts +115 -0
  23. package/commandDecorators/command.ts +8 -2
  24. package/commandDecorators/targetMeta.ts +1 -0
  25. package/createTunnel.test.ts +51 -0
  26. package/createTunnel.ts +32 -14
  27. package/executors.test.ts +21 -0
  28. package/executors.ts +9 -2
  29. package/frontendBuild/csrArtifactBuilder.ts +10 -1
  30. package/frontendBuild/cssCandidateCache.test.ts +10 -0
  31. package/frontendBuild/cssCandidateCache.ts +4 -2
  32. package/frontendBuild/pagesBundleBuilder.ts +5 -1
  33. package/frontendBuild/ssrBaseArtifactBuilder.ts +6 -2
  34. package/incrementalBuilder/buildBatch.proc.ts +14 -2
  35. package/incrementalBuilder/buildBatchProtocol.ts +3 -2
  36. package/incrementalBuilder/incrementalBuilder.proc.test.ts +96 -0
  37. package/incrementalBuilder/incrementalBuilder.proc.ts +30 -1
  38. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +6 -0
  39. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +6 -0
  40. package/lint/no-import-external-library-in-test.grit +24 -0
  41. package/mobile/desktopServerStage.test.ts +6 -0
  42. package/mobile/desktopServerStage.ts +25 -3
  43. package/mobile/index.ts +1 -0
  44. package/mobile/nativeApp.test.ts +1 -0
  45. package/mobile/nativeApp.ts +96 -34
  46. package/mobile/nativeConfig.test.ts +59 -1
  47. package/mobile/nativeConfig.ts +13 -3
  48. package/mobile/serverBundleSecrets.test.ts +61 -0
  49. package/mobile/serverBundleSecrets.ts +85 -0
  50. package/mobile/updateUploader.test.ts +49 -0
  51. package/mobile/updateUploader.ts +71 -0
  52. package/package.json +3 -2
  53. package/pipeline/PipelineCompiler.test.ts +631 -0
  54. package/pipeline/PipelineCompiler.ts +646 -0
  55. package/pipeline/PipelineDefinition.test.ts +89 -0
  56. package/pipeline/PipelineDefinition.ts +117 -0
  57. package/pipeline/PipelineDryRun.test.ts +93 -0
  58. package/pipeline/PipelineDryRun.ts +89 -0
  59. package/pipeline/PipelineInputReader.ts +739 -0
  60. package/pipeline/index.ts +4 -0
  61. package/qualityScanner.test.ts +9 -3
  62. package/qualityScanner.ts +5 -2
  63. package/scanInfo.ts +13 -6
  64. package/subspace.test.ts +108 -4
  65. package/subspace.ts +162 -10
  66. package/subspaceConfig.ts +11 -14
  67. package/testHelpers.ts +4 -0
  68. package/transforms/externalizeFrameworkPlugin.ts +1 -7
  69. package/transforms/serverEnvPlugin.test.ts +93 -2
  70. package/transforms/serverEnvPlugin.ts +71 -18
  71. package/transforms/serverEnvProfile.test.ts +82 -0
  72. package/transforms/serverEnvProfile.ts +114 -0
  73. package/typecheck/projectTypecheck.test.ts +32 -0
  74. package/typecheck/projectTypecheck.ts +71 -0
  75. package/workspaceLayout.test.ts +80 -3
  76. package/workspaceLayout.ts +15 -4
@@ -0,0 +1,195 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { createHash } from "node:crypto";
3
+ import { writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import type { Workspace } from "../commandDecorators";
6
+ import { tempDirs } from "../testHelpers";
7
+ import { CloudApi } from "./cloudApi";
8
+ import { UpdatePackUpload } from "./updatePackUpload";
9
+
10
+ interface Put {
11
+ uploadId: string;
12
+ headers: Record<string, string>;
13
+ body: Uint8Array;
14
+ }
15
+
16
+ class FakeAkasys {
17
+ readonly server: ReturnType<typeof Bun.serve>;
18
+ readonly requests: Record<string, unknown>[] = [];
19
+ readonly puts: Put[] = [];
20
+ readonly commits: string[] = [];
21
+ putStatuses: number[] = [];
22
+ commitAnswers: number[] = [];
23
+ release: Record<string, unknown> = { id: "release-1", status: "published", problems: [] };
24
+ requestRefusal: Response | null = null;
25
+
26
+ constructor() {
27
+ this.server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (request) => this.#route(request) });
28
+ }
29
+
30
+ get host() {
31
+ return `http://127.0.0.1:${this.server.port}`;
32
+ }
33
+
34
+ reset() {
35
+ this.requests.length = 0;
36
+ this.puts.length = 0;
37
+ this.commits.length = 0;
38
+ this.putStatuses = [];
39
+ this.commitAnswers = [];
40
+ this.requestRefusal = null;
41
+ }
42
+
43
+ async #route(request: Request) {
44
+ const url = new URL(request.url);
45
+ if (url.pathname === "/api/requestUpdateUpload/project-1") {
46
+ if (this.requestRefusal) return this.requestRefusal;
47
+ this.requests.push({ ...(await request.json()), authorization: request.headers.get("authorization") });
48
+ const uploadId = `upload-${this.requests.length}`;
49
+ return Response.json({
50
+ uploadId,
51
+ uploadUrl: `${this.host}/storage/${uploadId}?X-Amz-Signature=secret-signature`,
52
+ expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
53
+ contentType: "application/gzip",
54
+ });
55
+ }
56
+ if (url.pathname.startsWith("/storage/") && request.method === "PUT") {
57
+ this.puts.push({
58
+ uploadId: url.pathname.slice("/storage/".length),
59
+ headers: Object.fromEntries(request.headers),
60
+ body: new Uint8Array(await request.arrayBuffer()),
61
+ });
62
+ return new Response(null, { status: this.putStatuses.shift() ?? 200 });
63
+ }
64
+ if (url.pathname === "/api/commitUpdateUpload/project-1") {
65
+ const { uploadId } = (await request.json()) as { uploadId: string };
66
+ this.commits.push(uploadId);
67
+ const answer = this.commitAnswers.shift();
68
+ if (answer) return Response.json({ error: "release.error.uploadCommitInProgress" }, { status: answer });
69
+ return Response.json({ ...this.release, uploadId });
70
+ }
71
+ return new Response("not found", { status: 404 });
72
+ }
73
+ }
74
+
75
+ const makeDir = tempDirs("akan-update-upload-");
76
+ const akasys = new FakeAkasys();
77
+ const api = new CloudApi({} as Workspace, {
78
+ host: akasys.host,
79
+ auth: { accessToken: { jwt: "jwt-1", refreshToken: null, expiresAt: null } },
80
+ });
81
+ const fields = { appName: "lu", target: "default", platform: "android" as const, channel: "develop", notes: null };
82
+ const quick = { putRetryMs: 1, commitRetryMs: 1, cutOffWindowMs: 1_000, commitLimitMs: 1_000 };
83
+
84
+ const archiveOf = async (bytes: string) => {
85
+ const archive = path.join(await makeDir(), "pack.tgz");
86
+ await writeFile(archive, bytes);
87
+ return archive;
88
+ };
89
+
90
+ beforeEach(() => akasys.reset());
91
+ afterAll(() => akasys.server.stop(true));
92
+
93
+ describe("UpdatePackUpload", () => {
94
+ test("announces the archive's size and digest, PUTs it with nothing but its content type, then commits", async () => {
95
+ const archive = await archiveOf("pack bytes");
96
+
97
+ const release = await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
98
+
99
+ expect(release).toEqual({ id: "release-1", status: "published", problems: [], uploadId: "upload-1" });
100
+ expect(akasys.requests).toEqual([
101
+ {
102
+ ...fields,
103
+ size: 10,
104
+ sha256: createHash("sha256").update("pack bytes").digest("hex"),
105
+ authorization: "Bearer jwt-1",
106
+ },
107
+ ]);
108
+ const [put] = akasys.puts;
109
+ expect(new TextDecoder().decode(put?.body)).toBe("pack bytes");
110
+ expect(put?.headers["content-type"]).toBe("application/gzip");
111
+ expect(put?.headers["content-length"]).toBe("10");
112
+ expect(put?.headers.authorization).toBeUndefined();
113
+ expect(Object.keys(put?.headers ?? {}).filter((name) => name.startsWith("x-amz-"))).toEqual([]);
114
+ expect(akasys.commits).toEqual(["upload-1"]);
115
+ });
116
+
117
+ test("takes one fresh ticket when storage answers 403, and gives up on the second without naming the URL", async () => {
118
+ const archive = await archiveOf("pack");
119
+ akasys.putStatuses = [403];
120
+
121
+ await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
122
+ expect(akasys.puts.map(({ uploadId }) => uploadId)).toEqual(["upload-1", "upload-2"]);
123
+ expect(akasys.commits).toEqual(["upload-2"]);
124
+
125
+ akasys.reset();
126
+ akasys.putStatuses = [403, 403];
127
+ const refusal = await new UpdatePackUpload(api, "project-1", quick).send(archive, fields).catch((e: Error) => e);
128
+ expect(String(refusal)).toContain("HTTP 403");
129
+ expect(String(refusal)).not.toContain("secret-signature");
130
+ expect(akasys.commits).toEqual([]);
131
+ });
132
+
133
+ test("retries a PUT the way curl --retry does, and stops at a refusal that is not transient", async () => {
134
+ const archive = await archiveOf("pack");
135
+ akasys.putStatuses = [503, 502];
136
+ await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
137
+ expect(akasys.puts.map(({ uploadId }) => uploadId)).toEqual(["upload-1", "upload-1", "upload-1"]);
138
+
139
+ akasys.reset();
140
+ akasys.putStatuses = [400];
141
+ await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("HTTP 400");
142
+ expect(akasys.puts).toHaveLength(1);
143
+ });
144
+
145
+ test("asks again with the same id while the commit is cut off or still running, but not past a real answer", async () => {
146
+ const archive = await archiveOf("pack");
147
+ akasys.commitAnswers = [524, 409, 502];
148
+ await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
149
+ expect(akasys.commits).toEqual(["upload-1", "upload-1", "upload-1", "upload-1"]);
150
+
151
+ akasys.reset();
152
+ akasys.commitAnswers = [404];
153
+ await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("404");
154
+ expect(akasys.commits).toEqual(["upload-1"]);
155
+
156
+ akasys.reset();
157
+ akasys.commitAnswers = Array.from({ length: 100 }, () => 524);
158
+ const cutOff = { putRetryMs: 1, commitRetryMs: 10, cutOffWindowMs: 35, commitLimitMs: 10_000 };
159
+ await expect(new UpdatePackUpload(api, "project-1", cutOff).send(archive, fields)).rejects.toThrow("524");
160
+ expect(akasys.commits.length).toBeLessThan(6);
161
+ });
162
+
163
+ test("keeps waiting on a live claim past the cut-off window, up to the overall limit", async () => {
164
+ const archive = await archiveOf("pack");
165
+ akasys.commitAnswers = [...Array.from({ length: 6 }, () => 409), 524, ...Array.from({ length: 6 }, () => 409)];
166
+ const timing = { putRetryMs: 1, commitRetryMs: 10, cutOffWindowMs: 25, commitLimitMs: 10_000 };
167
+ await new UpdatePackUpload(api, "project-1", timing).send(archive, fields);
168
+ expect(akasys.commits).toHaveLength(14);
169
+
170
+ akasys.reset();
171
+ akasys.commitAnswers = Array.from({ length: 100 }, () => 409);
172
+ const limited = { putRetryMs: 1, commitRetryMs: 10, cutOffWindowMs: 10_000, commitLimitMs: 35 };
173
+ await expect(new UpdatePackUpload(api, "project-1", limited).send(archive, fields)).rejects.toThrow("409");
174
+ expect(akasys.commits.length).toBeLessThan(6);
175
+ });
176
+
177
+ test("tells a server without staged uploads apart from one whose 404 is a refusal", async () => {
178
+ const archive = await archiveOf("pack");
179
+ akasys.requestRefusal = new Response("Not Found", { status: 404 });
180
+ await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow(
181
+ "does not take direct pack uploads yet",
182
+ );
183
+
184
+ akasys.requestRefusal = Response.json({ error: "devProject.error.appNotFound", statusCode: 404 }, { status: 404 });
185
+ const refusal = await new UpdatePackUpload(api, "project-1", quick).send(archive, fields).catch((e: Error) => e);
186
+ expect(String(refusal)).toContain("devProject.error.appNotFound");
187
+ expect(akasys.puts).toEqual([]);
188
+ });
189
+
190
+ test("refuses an empty archive before asking for a ticket", async () => {
191
+ const archive = await archiveOf("");
192
+ await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("0 bytes");
193
+ expect(akasys.requests).toEqual([]);
194
+ });
195
+ });
@@ -0,0 +1,115 @@
1
+ import { Logger, sleep } from "akanjs/common";
2
+ import type { BunFile } from "bun";
3
+ import { CloudApi, type CloudRecord, type UpdateUploadRequest } from "./cloudApi";
4
+
5
+ interface UpdatePackUploadOptions {
6
+ putRetryMs?: number;
7
+ commitRetryMs?: number;
8
+ cutOffWindowMs?: number;
9
+ commitLimitMs?: number;
10
+ }
11
+
12
+ //* akasys sits behind Cloudflare's 100 s request cut, so the pack goes straight to its staging bucket and akasys
13
+ //* only sees two small JSON calls: request a presigned PUT, then commit the object, which akasys re-hashes.
14
+ export class UpdatePackUpload {
15
+ static readonly maxBytes = 200 * 1024 * 1024;
16
+ static readonly #transientPut = new Set([0, 408, 429, 500, 502, 503, 504]);
17
+ readonly #api: CloudApi;
18
+ readonly #devProjectId: string;
19
+ readonly #putRetryMs: number;
20
+ readonly #commitRetryMs: number;
21
+ readonly #cutOffWindowMs: number;
22
+ readonly #commitLimitMs: number;
23
+
24
+ constructor(
25
+ api: CloudApi,
26
+ devProjectId: string,
27
+ {
28
+ putRetryMs = 1_000,
29
+ commitRetryMs = 10_000,
30
+ cutOffWindowMs = 15 * 60_000,
31
+ commitLimitMs = 60 * 60_000,
32
+ }: UpdatePackUploadOptions = {},
33
+ ) {
34
+ this.#api = api;
35
+ this.#devProjectId = devProjectId;
36
+ this.#putRetryMs = putRetryMs;
37
+ this.#commitRetryMs = commitRetryMs;
38
+ this.#cutOffWindowMs = cutOffWindowMs;
39
+ this.#commitLimitMs = commitLimitMs;
40
+ }
41
+
42
+ async send(archive: string, fields: Omit<UpdateUploadRequest, "size" | "sha256">): Promise<CloudRecord> {
43
+ const file = Bun.file(archive);
44
+ if (file.size < 1 || file.size > UpdatePackUpload.maxBytes)
45
+ throw new Error(`The pack archive is ${file.size} bytes; a project takes 1 byte to 200 MiB.`);
46
+ const hasher = new Bun.CryptoHasher("sha256");
47
+ for await (const chunk of file.stream()) hasher.update(chunk);
48
+ const upload = { ...fields, size: file.size, sha256: hasher.digest("hex") };
49
+ //? A URL lives 15 minutes and storage answers 403 past it: one fresh ticket, then the refusal is real.
50
+ for (let ticket = 1; ; ticket++) {
51
+ const { uploadId, uploadUrl, contentType } = await this.#request(upload);
52
+ const status = await this.#put(uploadUrl, contentType, file);
53
+ if (status >= 200 && status < 300) return await this.#commit(uploadId);
54
+ if (status !== 403 || ticket === 2)
55
+ throw new Error(`The storage refused the pack upload (${status ? `HTTP ${status}` : "no response"}).`);
56
+ }
57
+ }
58
+
59
+ async #request(upload: UpdateUploadRequest) {
60
+ return await this.#api.requestUpdateUpload(this.#devProjectId, upload).catch((error: unknown) => {
61
+ //? A server older than staged uploads has no such route: its 404 is a page, where every refusal is an Err key.
62
+ const message = error instanceof Error ? error.message : String(error);
63
+ if (CloudApi.statusOf(error) === 404 && !/"error"\s*:\s*"[\w$-]+\.error\.[\w$-]+"/.test(message))
64
+ throw new Error(
65
+ "The project's server does not take direct pack uploads yet: update akasys and try again, or upload the pack from the project's release screen.",
66
+ );
67
+ throw error;
68
+ });
69
+ }
70
+
71
+ // The URL signs only its host: an unsigned x-amz-* header (a checksum included) makes storage refuse the PUT.
72
+ // A Bun fetch error names the URL, a bearer credential, so nothing but the status leaves this method.
73
+ async #put(url: string, contentType: string, file: BunFile) {
74
+ for (let retry = 0; ; retry++) {
75
+ const status = await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": contentType } })
76
+ .then(async (response) => {
77
+ await response.arrayBuffer().catch(() => undefined);
78
+ return response.status;
79
+ })
80
+ .catch(() => 0);
81
+ if (retry === 3 || !UpdatePackUpload.#transientPut.has(status)) return status;
82
+ await sleep(this.#putRetryMs * (retry + 1));
83
+ }
84
+ }
85
+
86
+ // The server finishes a commit whoever stopped listening, and the same id answers the same release afterwards.
87
+ // A 409 is a live claim the server heartbeats, so only the overall limit bounds it; a run of cut-offs with no 409
88
+ // between them may be a server that died, whose claim lapses within minutes, so that run gets the shorter window.
89
+ async #commit(uploadId: string) {
90
+ const limit = performance.now() + this.#commitLimitMs;
91
+ let cutOffSince: number | null = null;
92
+ for (;;) {
93
+ try {
94
+ return await this.#api.commitUpdateUpload(this.#devProjectId, uploadId);
95
+ } catch (error) {
96
+ const now = performance.now();
97
+ if (CloudApi.statusOf(error) === 409) cutOffSince = null;
98
+ else if (UpdatePackUpload.#cutOff(error)) cutOffSince ??= now;
99
+ else throw error;
100
+ const deadline = cutOffSince === null ? limit : Math.min(limit, cutOffSince + this.#cutOffWindowMs);
101
+ if (now + this.#commitRetryMs > deadline) throw error;
102
+ Logger.rawLog(
103
+ `The project is still taking the pack; asking again in ${Math.round(this.#commitRetryMs / 1000)}s.`,
104
+ );
105
+ await sleep(this.#commitRetryMs);
106
+ }
107
+ }
108
+ }
109
+
110
+ static #cutOff(error: unknown) {
111
+ const status = CloudApi.statusOf(error);
112
+ if (status === null) return !(error instanceof SyntaxError);
113
+ return status === 502 || status === 504 || (status >= 520 && status <= 527);
114
+ }
115
+ }
@@ -288,9 +288,10 @@ export const getInternalArgumentValue = async (
288
288
  };
289
289
 
290
290
  export const runCommands = async (...commands: CommandCls[]) => {
291
+ let failureExitCode = 1;
291
292
  process.on("unhandledRejection", (error) => {
292
293
  printCliError(error);
293
- process.exit(1);
294
+ process.exit(failureExitCode);
294
295
  });
295
296
  const __dirname = getDirname(import.meta.url);
296
297
  const packageJsonCandidates = [`${path.dirname(Bun.main)}/package.json`, `${__dirname}/../package.json`];
@@ -335,6 +336,10 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
335
336
  let programCommand = program.command(commandName, {
336
337
  hidden: targetMeta.targetOption.devOnly,
337
338
  });
339
+ const targetFailureExitCode = targetMeta.targetOption.failureExitCode;
340
+ //? commander exits 1 on a flag or argument it refuses, before any action runs.
341
+ if (targetFailureExitCode !== undefined)
342
+ programCommand.exitOverride((error) => process.exit(error.exitCode === 0 ? 0 : targetFailureExitCode));
338
343
  const [allArgMetas] = getArgMetas(command, targetMeta.key);
339
344
  for (const argMeta of allArgMetas) {
340
345
  if (argMeta.type === "Option") programCommand = handleOption(programCommand, argMeta);
@@ -364,6 +369,7 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
364
369
  };
365
370
 
366
371
  programCommand.action(async (...args: unknown[]) => {
372
+ failureExitCode = targetFailureExitCode ?? 1;
367
373
  if (!targetMeta.targetOption.stdio) Logger.rawLog();
368
374
  const cmdArgs = args.slice(0, args.length - 2);
369
375
  const opt = args[args.length - 2] as Record<string, unknown>;
@@ -413,6 +419,6 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
413
419
  // Handled here, not by the handler above: a rejection reaching the entry's top-level await prints Bun's own trace.
414
420
  await program.parseAsync(process.argv).catch((error: unknown) => {
415
421
  printCliError(error);
416
- process.exit(1);
422
+ process.exit(failureExitCode);
417
423
  });
418
424
  };
@@ -43,6 +43,7 @@ export interface TargetOption {
43
43
  desc?: string;
44
44
  runsOnWorkspaceRoot?: boolean;
45
45
  stdio?: boolean;
46
+ failureExitCode?: number;
46
47
  }
47
48
 
48
49
  export const Commands = () => {
@@ -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/executors.test.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  import { DevGeneratedIndexSync } from "./frontendBuild/devGeneratedIndexSync";
15
15
  import { AppInfo } from "./scanInfo";
16
16
  import {
17
+ createTempApp,
17
18
  createTempLib,
18
19
  formatWithBiome,
19
20
  hasBiome,
@@ -167,6 +168,26 @@ describe("Executor filesystem helpers", () => {
167
168
  expect(formatted).toEqual([path.join(root, "apps/demo/page/task/_index.tsx")]);
168
169
  });
169
170
 
171
+ test("an app hands its scaffolded files to the formatter too", async () => {
172
+ const { root, app } = trackRoot(await createTempApp("demo"));
173
+ const formatted: string[] = [];
174
+ app.getLinter = () =>
175
+ ({
176
+ fixFiles: async (filePaths: string[]) => {
177
+ formatted.push(...filePaths);
178
+ return { fixed: [] };
179
+ },
180
+ }) as unknown as ReturnType<Executor["getLinter"]>;
181
+
182
+ await app.applyTemplate({
183
+ basePath: "page/task",
184
+ template: "crudSinglePage",
185
+ dict: { model: "task", appName: "demo" },
186
+ });
187
+
188
+ expect(formatted).toEqual([path.join(root, "apps/demo/page/task/_index.tsx")]);
189
+ });
190
+
170
191
  test("a formatter that cannot run does not fail the scaffold", async () => {
171
192
  // create-akan-workspace scaffolds before `bun install`, so Biome may be absent; a lint fix beats a failed scaffold.
172
193
  const root = await makeTempRoot();
package/executors.ts CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  scanScopeRecipes,
41
41
  upsertAgentBlock,
42
42
  } from "./agentsIndex";
43
- import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
43
+ import { AkanAppConfig, AkanLibConfig, AkanWorkspaceConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
44
44
  import { getRootBoundarySegments, isRootBoundarySegments } from "./artifact/implicitRootLayout";
45
45
  import { CodegenLock } from "./codegenLock";
46
46
  import { FileSys, getDirname } from "./fileSys";
@@ -533,6 +533,7 @@ export class Executor {
533
533
  async applyTemplate(options: {
534
534
  basePath: string;
535
535
  template: string;
536
+ scanInfo?: AppInfo | LibInfo | null;
536
537
  dict?: { [key: string]: string };
537
538
  options?: { [key: string]: unknown };
538
539
  overwrite?: boolean;
@@ -707,6 +708,12 @@ export class WorkspaceExecutor extends Executor {
707
708
  async scan(): Promise<WorkspaceInfo> {
708
709
  return await WorkspaceInfo.fromExecutor(this);
709
710
  }
711
+ #akanConfig: AkanWorkspaceConfig | null = null;
712
+ async getConfig({ refresh }: { refresh?: boolean } = {}) {
713
+ if (this.#akanConfig && !refresh) return this.#akanConfig;
714
+ this.#akanConfig = await AkanWorkspaceConfig.from(this, { bustImportCache: refresh });
715
+ return this.#akanConfig;
716
+ }
710
717
  async getApps() {
711
718
  return await this.#getDirHasFile(`${this.workspaceRoot}/apps`, "akan.config.ts");
712
719
  }
@@ -1091,7 +1098,7 @@ export class SysExecutor extends Executor {
1091
1098
  overwrite?: boolean;
1092
1099
  }): Promise<FileContent[]> {
1093
1100
  const scanInfo = await this.scan();
1094
- const fileContents = await this._applyTemplate({ ...options, scanInfo, dict: withCapitalizedDict(options.dict) });
1101
+ const fileContents = await super.applyTemplate({ ...options, scanInfo });
1095
1102
  await this.scan();
1096
1103
  return fileContents;
1097
1104
  }
@@ -18,11 +18,19 @@ export class CsrArtifactBuilder {
18
18
  #app: App;
19
19
  #command: "build" | "start";
20
20
  #lang: string;
21
+ #cssAssets: Record<string, CssAsset> | null;
21
22
 
22
- constructor(app: App, command: "build" | "start" = "start", lang = "en") {
23
+ //? `cssAssets` is a dev batch's current stylesheets: `base-artifact.json` keeps the ones the boot build compiled.
24
+ constructor(
25
+ app: App,
26
+ command: "build" | "start" = "start",
27
+ lang = "en",
28
+ { cssAssets = null }: { cssAssets?: Record<string, CssAsset> | null } = {},
29
+ ) {
23
30
  this.#app = app;
24
31
  this.#command = command;
25
32
  this.#lang = lang;
33
+ this.#cssAssets = cssAssets;
26
34
  }
27
35
 
28
36
  async build(): Promise<BuildCsrArtifactResult | null> {
@@ -152,6 +160,7 @@ void bootCsr(pages);
152
160
  }
153
161
 
154
162
  async #loadCssAssets(): Promise<Record<string, CssAsset>> {
163
+ if (this.#cssAssets) return this.#cssAssets;
155
164
  const artifactFile = Bun.file(path.join(this.#artifactDir, "base-artifact.json"));
156
165
  if (!(await artifactFile.exists())) return {};
157
166
  const artifact = (await artifactFile.json()) as Partial<Pick<BaseBuildArtifact, "cssAssets">>;
@@ -23,6 +23,16 @@ describe("CssCandidateCache token extraction", () => {
23
23
  expect(candidates).not.toContain("_td");
24
24
  });
25
25
 
26
+ test("keeps Tailwind v4's variable shorthand whole, and leaves a call's argument its own candidate", async () => {
27
+ const candidates = await scan(
28
+ `const c = "max-md:top-(--keyboard-top) min-h-(--x) bg-(color:--brand)/50"; cn("flex"); fn(value);`,
29
+ );
30
+ expect(candidates).toEqual(
31
+ expect.arrayContaining(["max-md:top-(--keyboard-top)", "min-h-(--x)", "bg-(color:--brand)/50", "flex"]),
32
+ );
33
+ expect(candidates).not.toContain("-keyboard-top");
34
+ });
35
+
26
36
  test("ignores a labelled tuple type that only looks like an arbitrary property", async () => {
27
37
  const candidates = await scan(
28
38
  `type A = [key: string, make: () => unknown][];\ntype B = [setQueryArgs: (a: Args) => Args, options?: Policy];`,
@@ -2,7 +2,9 @@ import { stat } from "node:fs/promises";
2
2
 
3
3
  // The leading `[` alternation keeps arbitrary variants (`[&_td]:px-3`) whole. Brackets hold no whitespace: a labelled
4
4
  // tuple type scanned as a class compiles to a rule lightningcss cannot parse, and it then drops every variant rule.
5
- const CANDIDATE_RE = /-?(?:[\w@]|\[[^\]\s]+\])[\w:/.-]*(?:\[[^\]\s]+\][\w:/.-]*)*/g;
5
+ // Parentheses pass only as Tailwind v4's variable shorthand, `(--x)` or `(length:--x)`, so `cn("flex")` still yields
6
+ // `flex` on its own.
7
+ const CANDIDATE_RE = /-?(?:[\w@]|\[[^\]\s]+\])[\w:/.-]*(?:(?:\[[^\]\s]+\]|\((?:[a-z-]+:)?--[^)\s]+\))[\w:/.-]*)*/g;
6
8
 
7
9
  interface CachedFile {
8
10
  mtimeMs: number;
@@ -18,7 +20,7 @@ interface CacheFile {
18
20
  /** Tailwind candidates per source file, on disk: the batch worker that compiles CSS exits before the next save. */
19
21
  export class CssCandidateCache {
20
22
  // Bump when the token regex or the entry shape changes, so stale extractions are not reused.
21
- static readonly #version = 3;
23
+ static readonly #version = 4;
22
24
  readonly #path: string;
23
25
  readonly #entries = new Map<string, CachedFile>();
24
26
  #dirty = false;
@@ -32,11 +32,13 @@ export class PagesBundleBuilder {
32
32
  #command: "build" | "start";
33
33
  #pageEntries?: PageEntry[];
34
34
  #started = Date.now();
35
+ #serverEnv: BunPlugin | undefined;
35
36
 
36
- constructor(app: App, command: "build" | "start" = "start", pageEntries?: PageEntry[]) {
37
+ constructor(app: App, command: "build" | "start" = "start", pageEntries?: PageEntry[], serverEnv?: BunPlugin) {
37
38
  this.#app = app;
38
39
  this.#command = command;
39
40
  this.#pageEntries = pageEntries;
41
+ this.#serverEnv = serverEnv;
40
42
  }
41
43
 
42
44
  async build(): Promise<BuildPagesBundleResult> {
@@ -67,6 +69,8 @@ export class PagesBundleBuilder {
67
69
  define: bundleDefine(this.#app, this.#command, "ssr"),
68
70
  metafile: dev,
69
71
  plugins: [
72
+ //? First: the use-client plugins below load every module, and an env file must be swapped before they do.
73
+ ...(this.#serverEnv ? [this.#serverEnv] : []),
70
74
  PagesBundleBuilder.createPagesEntryPlugin(entrySource),
71
75
  PagesBundleBuilder.createCssStubPlugin(),
72
76
  PagesBundleBuilder.createServerUseClientFetchPlugin(),
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { optimize } from "@tailwindcss/node";
3
3
  import type { AkanNativeAppId, AkanWebConfig } from "akanjs";
4
4
  import type { BaseBuildArtifact } from "akanjs/server";
5
+ import type { BunPlugin } from "bun";
5
6
  import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
6
7
  import { computeRouteSeedIndex, type RouteSeedIndex, saveRouteSeedIndex } from "../artifact/routeSeedIndex";
7
8
  import type { App } from "../commandDecorators";
@@ -29,10 +30,13 @@ export class SsrBaseArtifactBuilder {
29
30
  #artifactDir: string;
30
31
  #absArtifactDir: string;
31
32
  #started = Date.now();
33
+ #serverEnv: BunPlugin | undefined;
32
34
 
33
- constructor(app: App, command: "build" | "start" = "start") {
35
+ /** `serverEnv`: the build's server env plugin, so the route bundle carries the env files the backend does and no more. */
36
+ constructor(app: App, command: "build" | "start" = "start", { serverEnv }: { serverEnv?: BunPlugin } = {}) {
34
37
  this.#app = app;
35
38
  this.#command = command;
39
+ this.#serverEnv = serverEnv;
36
40
  this.#artifactDir = `${command === "build" ? app.dist.cwdPath : app.cwdPath}/.akan/artifact`;
37
41
  this.#absArtifactDir = path.resolve(this.#artifactDir);
38
42
  }
@@ -52,7 +56,7 @@ export class SsrBaseArtifactBuilder {
52
56
 
53
57
  const pageEntries = await resolveSsrPageEntriesForApp(this.#app, pageKeys);
54
58
  const { cssCompiler, optimizedFonts, cssAssets } = await this.#buildStyleAssets();
55
- const pagesBundle = await new PagesBundleBuilder(this.#app, this.#command, pageEntries).build();
59
+ const pagesBundle = await new PagesBundleBuilder(this.#app, this.#command, pageEntries, this.#serverEnv).build();
56
60
  this.#app.verbose(
57
61
  `[base-artifact] pages bundle -> ${pagesBundle.bundlePath} (buildId=${pagesBundle.buildId}, splitting=${pagesBundle.splitting}, entry=${pagesBundle.entryBytes} bytes, outputs=${pagesBundle.outputCount}, chunks=${pagesBundle.chunkCount}, total=${pagesBundle.outputBytes} bytes)`,
58
62
  );