@akanjs/devkit 3.0.0-beta.27 → 3.0.0-beta.29

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 (40) hide show
  1. package/akanApp/akanApp.host.test.ts +55 -0
  2. package/akanApp/akanApp.host.ts +24 -2
  3. package/akanConfig/akanBin.test.ts +62 -0
  4. package/akanConfig/akanBin.ts +78 -0
  5. package/akanConfig/akanConfig.test.ts +212 -2
  6. package/akanConfig/akanConfig.ts +93 -26
  7. package/akanConfig/index.ts +1 -0
  8. package/akanConfig/types.ts +7 -1
  9. package/applicationBuildRunner.test.ts +36 -2
  10. package/applicationBuildRunner.ts +21 -6
  11. package/biome.base.json +3 -1
  12. package/csrE2e/csrPageStack.e2e.test.ts +65 -0
  13. package/executors.test.ts +43 -0
  14. package/executors.ts +17 -2
  15. package/frontendBuild/csrDevModuleCompiler.test.ts +32 -0
  16. package/frontendBuild/csrDevModuleCompiler.ts +45 -2
  17. package/frontendBuild/ssrBaseArtifactBuilder.ts +7 -2
  18. package/integration/devStability.integration.test.ts +35 -0
  19. package/mobile/desktopBin.test.ts +177 -0
  20. package/mobile/desktopBin.ts +143 -0
  21. package/mobile/desktopServerStage.test.ts +204 -0
  22. package/mobile/desktopServerStage.ts +138 -0
  23. package/mobile/index.ts +3 -0
  24. package/mobile/mobileTarget.test.ts +50 -1
  25. package/mobile/mobileTarget.ts +20 -4
  26. package/mobile/nativeApp.test.ts +52 -1
  27. package/mobile/nativeApp.ts +175 -20
  28. package/mobile/nativeAppLine.test.ts +7 -0
  29. package/mobile/nativeAppLine.ts +8 -0
  30. package/mobile/nativeConfig.test.ts +215 -4
  31. package/mobile/nativeConfig.ts +117 -8
  32. package/mobile/nativePluginFolders.test.ts +81 -0
  33. package/mobile/nativePluginFolders.ts +60 -0
  34. package/mobile/nativeWebDir.ts +6 -2
  35. package/package.json +2 -2
  36. package/transforms/serverEnvPlugin.test.ts +103 -0
  37. package/transforms/serverEnvPlugin.ts +41 -0
  38. package/types.ts +1 -0
  39. package/workspaceLayout.test.ts +5 -0
  40. package/workspaceLayout.ts +2 -0
@@ -117,6 +117,27 @@ describe("CsrDevModuleCompiler", () => {
117
117
  "",
118
118
  ].join("\n"),
119
119
  );
120
+ //? The shape of an app's useClient.ts: a macro whose module graph reaches server code that reads import.meta.
121
+ await Bun.write(
122
+ file("apps/demo/lib/macroDep.ts"),
123
+ 'import { createRequire } from "node:module";\nconst req = createRequire(import.meta.url);\nexport const depUrl = import.meta.url;\nexport const hasRequire = typeof req;\n',
124
+ );
125
+ await Bun.write(
126
+ file("apps/demo/lib/macro.ts"),
127
+ 'import { depUrl, hasRequire } from "./macroDep";\nexport const serialized = () => ({ depUrl, hasRequire });\n',
128
+ );
129
+ await Bun.write(
130
+ file("apps/demo/lib/useClient.ts"),
131
+ [
132
+ 'import { used } from "barrel";',
133
+ 'import { serialized } from "./macro" with { type: "macro" };',
134
+ "export const signal = serialized();",
135
+ "export const own = import.meta.url;",
136
+ 'export const label = "import.meta.url";',
137
+ "export const barrel = used;",
138
+ "",
139
+ ].join("\n"),
140
+ );
120
141
  await Bun.write(file("node_modules/with-bin/index.mjs"), "#!/usr/bin/env node\nexport const a = 1;\n");
121
142
  await Bun.write(file("node_modules/awaits/index.mjs"), "export const a = await Promise.resolve(1);\n");
122
143
  await Bun.write(file("apps/demo/ui/awaits.ts"), "export const a = await Promise.resolve(1);\n");
@@ -213,6 +234,17 @@ describe("CsrDevModuleCompiler", () => {
213
234
  });
214
235
  });
215
236
 
237
+ test("a macro runs with its modules' own import.meta, and the file importing it still reads the browser's", async () => {
238
+ const { modules } = await compile("apps/demo/lib/useClient.ts", { prepass: true });
239
+ const module = modules.find((compiled) => compiled.id === "apps/demo/lib/useClient.ts");
240
+ expect(module?.deps.some((dep) => dep.includes(`${path.join("node_modules", "barrel")}${path.sep}`))).toBe(true);
241
+ expect(run(module)).toMatchObject({
242
+ signal: { depUrl: Bun.pathToFileURL(file("apps/demo/lib/macroDep.ts")).href, hasRequire: "function" },
243
+ own: `${origin}/apps/demo/lib/useClient.ts`,
244
+ label: "import.meta.url",
245
+ });
246
+ });
247
+
216
248
  test("a file or re-export the browser build tree-shook away resolves with the browser condition", async () => {
217
249
  const { modules, resolver } = await compile("apps/demo/entry.ts", { prepass: true });
218
250
  const ids = modules.map((module) => module.id);
@@ -44,6 +44,7 @@ export interface CsrDevCompileResult {
44
44
  //* modules it changed instead of reloading one scope-hoisted file.
45
45
  export class CsrDevModuleCompiler {
46
46
  static readonly #storeRoot = /(?:^|\/)lib\/st\.ts$/;
47
+ static readonly #macroImport = /\b(?:with|assert)\s*\{\s*type\s*:\s*["']macro["']/;
47
48
  static readonly #factoryArgument =
48
49
  /(?<![\w$.])(?:require|module|exports|__akanImport|\$RefreshReg\$|\$RefreshSig\$)(?![\w$]|\s*:)/;
49
50
  static readonly #helperPatches = [
@@ -140,6 +141,7 @@ export class CsrDevModuleCompiler {
140
141
  ),
141
142
  );
142
143
  const misses: string[] = [];
144
+ const macroOutputs = vendor ? new Map<string, string>() : await this.#evaluateMacros(files);
143
145
  let result: Awaited<ReturnType<typeof Bun.build>>;
144
146
  try {
145
147
  result = await Bun.build({
@@ -157,7 +159,7 @@ export class CsrDevModuleCompiler {
157
159
  //? down, and Bun's cjs output keeps `import.meta.env` and spells `import.meta.url` as this disk's `file://` path.
158
160
  define: { ...this.#context.define, "import.meta": "__akanMeta" },
159
161
  optimizeImports: this.#context.optimizeImports,
160
- plugins: [this.#registryPlugin(vendor, misses)],
162
+ plugins: [this.#registryPlugin(vendor, misses, macroOutputs)],
161
163
  });
162
164
  } catch (error) {
163
165
  if (vendor && error instanceof AggregateError) return await this.#withoutFailed(files, error);
@@ -254,11 +256,52 @@ export class CsrDevModuleCompiler {
254
256
  };
255
257
  }
256
258
 
257
- #registryPlugin(vendor: boolean, misses: string[]): BunPlugin {
259
+ //? Bun applies `define` to the modules a macro imports too, and runs them in this process, where the `import.meta`
260
+ //? rewrite leaves a free `__akanMeta` that no factory header declares. So a file importing a macro has its macros run
261
+ //? first by a build of its own, with every other import left as written, and the module build reads that output.
262
+ async #evaluateMacros(files: string[]): Promise<Map<string, string>> {
263
+ const sources = await Promise.all(files.map(async (file) => [file, await Bun.file(file).text()] as const));
264
+ const importers = sources.filter(([, source]) => CsrDevModuleCompiler.#macroImport.test(source));
265
+ const outputs = await Promise.all(
266
+ importers.map(async ([file]) => {
267
+ const result = await Bun.build({
268
+ entrypoints: [file],
269
+ target: "browser",
270
+ format: "esm",
271
+ plugins: [
272
+ {
273
+ name: "akan-csr-macro-pass",
274
+ setup: (build) =>
275
+ void build.onResolve({ filter: /.*/ }, (args) =>
276
+ args.importer ? { path: args.path, external: true } : undefined,
277
+ ),
278
+ },
279
+ ],
280
+ });
281
+ const [output] = result.outputs;
282
+ if (!output) throw new Error(`[csr-dev] ${this.#paths.idOf(file)}: its macro pass wrote no output`);
283
+ const text = await output.text();
284
+ return [
285
+ [file, text],
286
+ [CsrDevPaths.realpath(file), text],
287
+ ] as const;
288
+ }),
289
+ );
290
+ return new Map(outputs.flat());
291
+ }
292
+
293
+ #registryPlugin(vendor: boolean, misses: string[], macroOutputs: Map<string, string>): BunPlugin {
258
294
  return {
259
295
  name: "akan-csr-registry",
260
296
  setup: (build) => {
261
297
  build.onLoad({ filter: /\.css$/ }, () => ({ contents: "", loader: "js" }));
298
+ if (macroOutputs.size > 0) {
299
+ const exact = [...macroOutputs.keys()].map((file) => file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
300
+ build.onLoad({ filter: new RegExp(`^(?:${exact.join("|")})$`) }, (args) => {
301
+ const contents = macroOutputs.get(args.path);
302
+ return contents === undefined ? undefined : { contents, loader: "js" };
303
+ });
304
+ }
262
305
  if (!vendor)
263
306
  build.onLoad({ filter: /\.tsx$/ }, async (args) => {
264
307
  if (CsrDevPaths.isVendorFile(args.path)) return undefined;
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { optimize } from "@tailwindcss/node";
3
- import type { AkanWebConfig } from "akanjs";
3
+ import type { AkanMobileAppId, AkanWebConfig } from "akanjs";
4
4
  import type { BaseBuildArtifact } from "akanjs/server";
5
5
  import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
6
6
  import { computeRouteSeedIndex, type RouteSeedIndex, saveRouteSeedIndex } from "../artifact/routeSeedIndex";
@@ -89,7 +89,8 @@ export class SsrBaseArtifactBuilder {
89
89
  .filter((target) => (target.deepLinks?.domains?.length ?? 0) > 0)
90
90
  .map((target) => ({
91
91
  targetName: target.name,
92
- appId: target.appId,
92
+ iosAppId: SsrBaseArtifactBuilder.#appIdOn(target.appId, "ios"),
93
+ androidAppId: SsrBaseArtifactBuilder.#appIdOn(target.appId, "android"),
93
94
  domains: target.deepLinks?.domains ?? [],
94
95
  iosTeamId: target.deepLinks?.ios?.teamId,
95
96
  androidSha256CertFingerprints: target.deepLinks?.android?.sha256CertFingerprints,
@@ -223,6 +224,10 @@ export class SsrBaseArtifactBuilder {
223
224
  }
224
225
 
225
226
  // Checked against the written asset, not the compiled text: it is the only stylesheet an SSR render serves.
227
+ static #appIdOn(appId: AkanMobileAppId, platform: "ios" | "android"): string | null {
228
+ return typeof appId === "string" ? appId : (appId[platform] ?? appId.default ?? null);
229
+ }
230
+
226
231
  static #warnDroppedImports(app: App, cssRelPath: string, css: string, imported: ImportedStylesheet[]) {
227
232
  for (const { cssPath, declaredNames } of imported) {
228
233
  if (declaredNames.length === 0 || declaredNames.some((name) => css.includes(`${name}:`))) continue;
@@ -153,6 +153,41 @@ describe("dev stability integration harness", () => {
153
153
  hmr?.close();
154
154
  });
155
155
 
156
+ integrationTest(
157
+ "server edits landing while a restart stops the backend leave the port to the backend it tracks",
158
+ async () => {
159
+ const harness = await createHarness();
160
+ const host = await harness.startHost();
161
+ const port = await harness.resolvePort();
162
+ const writeMarker = (label: string) =>
163
+ harness.writeFile("srvkit/backendMarker.ts", `export const backendMarker = "${label}";\n`);
164
+
165
+ const { mark } = await harness.editUntilSeen(host, (attempt) => writeMarker(`burst-first-${attempt}`), {
166
+ evidence: /stopping backend pid=\d+/,
167
+ });
168
+ // Each lands past the 120ms debounce, so a restart timer fires while the one before still waits for its exit.
169
+ for (let idx = 0; idx < 8; idx++) {
170
+ await writeMarker(`burst-${idx}`);
171
+ await new Promise((resolve) => setTimeout(resolve, 200));
172
+ }
173
+
174
+ const trackedPid = () =>
175
+ [
176
+ ...host.logs
177
+ .join("")
178
+ .slice(mark)
179
+ .matchAll(/backend spawned pid=(\d+)/g),
180
+ ].at(-1)?.[1];
181
+ const settled = await waitForGatewayHealth(
182
+ port,
183
+ (health) => String(health.pid) === trackedPid() && health.children.some((child) => child.ready),
184
+ );
185
+ expect(String(settled.pid)).toBe(trackedPid() ?? "");
186
+ const since = host.logs.join("").slice(mark);
187
+ expect(since).not.toMatch(/already in use|not starting a second one/);
188
+ },
189
+ );
190
+
156
191
  integrationTest("client-only valid edits refresh browser state without backend restart", async () => {
157
192
  const harness = await createHarness();
158
193
  const host = await harness.startHost();
@@ -0,0 +1,177 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import type { AkanBinConfig, BinPlatform } from "../akanConfig";
5
+ import { tempDirs } from "../testHelpers";
6
+ import { DesktopBin } from "./desktopBin";
7
+
8
+ const makeTempRoot = tempDirs("akan-desktop-bin-");
9
+ const here = DesktopBin.platform() as BinPlatform;
10
+ const elsewhere: BinPlatform = here === "linux-x64" ? "darwin-arm64" : "linux-x64";
11
+ const script = "#!/bin/sh\necho carried\n";
12
+
13
+ const fakeApp = (cwdPath: string, libs: string[] = []) =>
14
+ ({
15
+ name: "portal",
16
+ cwdPath,
17
+ getScanInfo: () => ({ getLibs: () => libs }),
18
+ logger: { info: () => {}, warn: () => {} },
19
+ spawn: async (command: string, args: string[], { cwd }: { cwd?: string } = {}) => {
20
+ const proc = Bun.spawnSync([command, ...args], { cwd });
21
+ if (proc.exitCode !== 0) throw new Error(proc.stderr.toString());
22
+ return proc.stdout.toString();
23
+ },
24
+ }) as never;
25
+
26
+ const stage = async (
27
+ root: string,
28
+ bin: AkanBinConfig,
29
+ { libBins = [], libs = [] }: { libBins?: { lib: string; bin: AkanBinConfig }[]; libs?: string[] } = {},
30
+ ) => {
31
+ const binDir = path.join(root, "apps/portal/.akan/mobile/default/bin");
32
+ const config = { app: { name: "portal" }, bin, libBins } as never;
33
+ const carried = await new DesktopBin(fakeApp(path.join(root, "apps/portal"), libs), config).stage(binDir);
34
+ return { carried, binDir };
35
+ };
36
+
37
+ let requests = 0;
38
+ const served = new Map<string, Uint8Array<ArrayBuffer>>();
39
+ const files = Bun.serve({
40
+ port: 0,
41
+ fetch(req) {
42
+ requests++;
43
+ const body = served.get(new URL(req.url).pathname);
44
+ return body ? new Response(body) : new Response("missing", { status: 404 });
45
+ },
46
+ });
47
+ afterAll(() => files.stop(true));
48
+ const hash = (bytes: Uint8Array | string) => new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
49
+
50
+ describe("DesktopBin", () => {
51
+ test("carries the app's own entries and those of the libs it depends on, the app's first", () => {
52
+ const own = { ffmpeg: { [here]: { path: "/a/ffmpeg" } } };
53
+ const lib = { ffmpeg: { [here]: { path: "/lib/ffmpeg" } }, ffprobe: { [here]: { path: "/lib/ffprobe" } } };
54
+ const other = { gst: { [here]: { path: "/other/gst" } } };
55
+ const chosen = DesktopBin.select(
56
+ {
57
+ app: { name: "portal" } as never,
58
+ bin: own,
59
+ libBins: [
60
+ { lib: "media", bin: lib },
61
+ { lib: "robotics", bin: other },
62
+ ],
63
+ },
64
+ ["media"],
65
+ );
66
+ expect([...chosen.entries()]).toEqual([
67
+ ["ffmpeg", { owner: "apps/portal/akan.config.ts", platforms: own.ffmpeg }],
68
+ ["ffprobe", { owner: "libs/media/akan.config.ts", platforms: lib.ffprobe }],
69
+ ]);
70
+ });
71
+
72
+ test("two libs may repeat an entry but not change it", () => {
73
+ const select = (second: AkanBinConfig) => () =>
74
+ DesktopBin.select(
75
+ {
76
+ app: { name: "portal" } as never,
77
+ bin: {},
78
+ libBins: [
79
+ { lib: "media", bin: { ffmpeg: { [here]: { path: "/lib/ffmpeg" } } } },
80
+ { lib: "video", bin: second },
81
+ ],
82
+ },
83
+ ["media", "video"],
84
+ );
85
+ expect(select({ ffmpeg: { [here]: { path: "/lib/ffmpeg" } } })).not.toThrow();
86
+ expect(select({ ffmpeg: { [here]: { path: "/video/ffmpeg" } } })).toThrow(
87
+ "libs/media/akan.config.ts and libs/video/akan.config.ts declare bin.ffmpeg differently",
88
+ );
89
+ });
90
+
91
+ test("keeps an extension on Windows, where PATH lookup needs one", () => {
92
+ expect(DesktopBin.fileName("ffmpeg", "/x/ffmpeg.exe", "win32")).toBe("ffmpeg.exe");
93
+ expect(DesktopBin.fileName("ffmpeg", "/x/ffmpeg", "win32")).toBe("ffmpeg.exe");
94
+ expect(DesktopBin.fileName("tool", "/x/tool.cmd", "win32")).toBe("tool.cmd");
95
+ expect(DesktopBin.fileName("ffmpeg", "/x/ffmpeg.exe", "darwin")).toBe("ffmpeg");
96
+ });
97
+
98
+ test("names a download by its URL's extension alone, so no URL writes outside the cache folder", () => {
99
+ expect(DesktopBin.downloadName("https://x.dev/r/ffmpeg-7.1-linux64.tar.xz")).toBe("download.tar.xz");
100
+ expect(DesktopBin.downloadName("https://x.dev/r/FFmpeg.ZIP?token=1")).toBe("download.zip");
101
+ expect(DesktopBin.downloadName("https://x.dev/r/ffmpeg.exe")).toBe("download.exe");
102
+ expect(DesktopBin.downloadName("https://x.dev/r/ffmpeg")).toBe("download");
103
+ expect(DesktopBin.downloadName("https://x.dev/r/ffmpeg%E0.zip")).toBe("download.zip");
104
+ expect(DesktopBin.downloadName("https://x.dev/a/..%2F..%2Fevil.zip")).toBe("download.zip");
105
+ expect(DesktopBin.downloadName("https://x.dev/a/..%5C..%5Cevil.zip")).toBe("download.zip");
106
+ });
107
+
108
+ test("unpacks with the OS's own tools", () => {
109
+ expect(DesktopBin.extractCommand("/c/a.zip", "/d", "linux")).toEqual(["unzip", "-q", "-o", "/c/a.zip", "-d", "/d"]);
110
+ expect(DesktopBin.extractCommand("/c/a.zip", "/d", "darwin")).toEqual(["tar", "-xf", "/c/a.zip", "-C", "/d"]);
111
+ expect(DesktopBin.extractCommand("C:\\c\\a.zip", "C:\\d", "win32")[0]).toMatch(/System32[\\/]tar\.exe$/);
112
+ });
113
+
114
+ test("copies a file into the app's bin folder, executable", async () => {
115
+ const root = await makeTempRoot();
116
+ writeFileSync(path.join(root, "tool"), script);
117
+ const { carried, binDir } = await stage(root, { tool: { [here]: { path: path.join(root, "tool") } } });
118
+ const staged = path.join(binDir, DesktopBin.fileName("tool", "tool"));
119
+ expect(carried).toEqual(["tool"]);
120
+ expect(readFileSync(staged, "utf8")).toBe(script);
121
+ if (process.platform !== "win32") expect(statSync(staged).mode & 0o111).toBe(0o111);
122
+ });
123
+
124
+ test("empties the folder first, so a bin taken out of the config is not carried again", async () => {
125
+ const root = await makeTempRoot();
126
+ writeFileSync(path.join(root, "tool"), script);
127
+ writeFileSync(path.join(root, "other"), script);
128
+ await stage(root, { tool: { [here]: { path: path.join(root, "tool") } } });
129
+ const { binDir } = await stage(root, { other: { [here]: { path: path.join(root, "other") } } });
130
+ expect(readdirSync(binDir)).toEqual([DesktopBin.fileName("other", "other")]);
131
+ });
132
+
133
+ test("downloads once, checks the hash, and reuses the file it kept", async () => {
134
+ const root = await makeTempRoot();
135
+ served.set("/ffmpeg", new TextEncoder().encode(script));
136
+ const bin = { ffmpeg: { [here]: { url: `${files.url}ffmpeg`, sha256: hash(script) } } };
137
+ const before = requests;
138
+ await stage(root, bin);
139
+ await stage(root, bin);
140
+ expect(requests - before).toBe(1);
141
+ await expect(
142
+ stage(await makeTempRoot(), { ffmpeg: { [here]: { url: `${files.url}ffmpeg`, sha256: "0".repeat(64) } } }),
143
+ ).rejects.toThrow(`hashes to ${hash(script)}, not the declared`);
144
+ });
145
+
146
+ test("takes the executable out of an archive", async () => {
147
+ const root = await makeTempRoot();
148
+ mkdirSync(path.join(root, "pkg/ffmpeg-7.1/bin"), { recursive: true });
149
+ writeFileSync(path.join(root, "pkg/ffmpeg-7.1/bin/ffmpeg"), script);
150
+ Bun.spawnSync(["tar", "-czf", path.join(root, "ffmpeg.tar.gz"), "-C", path.join(root, "pkg"), "ffmpeg-7.1"]);
151
+ served.set("/ffmpeg.tar.gz", new Uint8Array(readFileSync(path.join(root, "ffmpeg.tar.gz"))));
152
+ const { binDir } = await stage(root, {
153
+ ffmpeg: {
154
+ [here]: {
155
+ url: `${files.url}ffmpeg.tar.gz`,
156
+ sha256: hash(readFileSync(path.join(root, "ffmpeg.tar.gz"))),
157
+ file: "ffmpeg-7.1/bin/ffmpeg",
158
+ },
159
+ },
160
+ });
161
+ expect(readFileSync(path.join(binDir, DesktopBin.fileName("ffmpeg", "ffmpeg")), "utf8")).toBe(script);
162
+ await expect(
163
+ stage(await makeTempRoot(), {
164
+ ffmpeg: { [here]: { path: path.join(root, "ffmpeg.tar.gz"), file: "ffmpeg-7.1/bin/ffprobe" } },
165
+ }),
166
+ ).rejects.toThrow("the archive holds no ffmpeg-7.1/bin/ffprobe");
167
+ });
168
+
169
+ test("refuses an entry with no file for this computer, and carries nothing for a lib the app does not use", async () => {
170
+ const root = await makeTempRoot();
171
+ await expect(stage(root, { ffmpeg: { [elsewhere]: { path: "/x/ffmpeg" } } })).rejects.toThrow(
172
+ `apps/portal/akan.config.ts: bin.ffmpeg names no ${here} file`,
173
+ );
174
+ const unused = await stage(root, {}, { libBins: [{ lib: "media", bin: { ffmpeg: { [here]: { path: "/x" } } } }] });
175
+ expect(unused.carried).toEqual([]);
176
+ });
177
+ });
@@ -0,0 +1,143 @@
1
+ import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import {
4
+ type AkanAppConfig,
5
+ type AkanBinConfig,
6
+ type AkanBinSource,
7
+ type AkanBinUrlSource,
8
+ type BinPlatform,
9
+ binPlatforms,
10
+ } from "../akanConfig";
11
+ import type { App } from "../commandDecorators";
12
+
13
+ type BinPlatforms = AkanBinConfig[string];
14
+
15
+ //* A desktop app is built on the computer it runs on, so only that platform's file is fetched. It is copied byte for
16
+ //* byte into the folder the app carries as `desktop.bin`: no extended attribute, so no quarantine flag, travels
17
+ //* into the bundle.
18
+ export class DesktopBin {
19
+ constructor(
20
+ readonly app: App,
21
+ readonly config: AkanAppConfig,
22
+ ) {}
23
+
24
+ static platform(): BinPlatform | null {
25
+ const platform = `${process.platform}-${process.arch}`;
26
+ return (binPlatforms as readonly string[]).includes(platform) ? (platform as BinPlatform) : null;
27
+ }
28
+
29
+ /** The app's own entries, then those of the libs it depends on: the app's wins a name, two libs may not differ on one. */
30
+ static select(config: Pick<AkanAppConfig, "app" | "bin" | "libBins">, libs: string[]) {
31
+ const chosen = new Map<string, { owner: string; platforms: BinPlatforms }>();
32
+ for (const [name, platforms] of Object.entries(config.bin))
33
+ chosen.set(name, { owner: `apps/${config.app.name}/akan.config.ts`, platforms });
34
+ for (const { lib, bin } of config.libBins) {
35
+ if (!libs.includes(lib)) continue;
36
+ const owner = `libs/${lib}/akan.config.ts`;
37
+ for (const [name, platforms] of Object.entries(bin)) {
38
+ const held = chosen.get(name);
39
+ if (!held) chosen.set(name, { owner, platforms });
40
+ else if (held.owner.startsWith("libs/") && JSON.stringify(held.platforms) !== JSON.stringify(platforms))
41
+ throw new Error(
42
+ `${held.owner} and ${owner} declare bin.${name} differently; declare it in apps/${config.app.name}/akan.config.ts to pick one.`,
43
+ );
44
+ }
45
+ }
46
+ return chosen;
47
+ }
48
+
49
+ /** Windows finds an executable on PATH by its extension, so the carried file keeps one. */
50
+ static fileName(name: string, executable: string, platform: NodeJS.Platform = process.platform) {
51
+ if (platform !== "win32" || /\.(exe|cmd|bat|com)$/i.test(name)) return name;
52
+ return `${name}${path.extname(executable).toLowerCase() || ".exe"}`;
53
+ }
54
+
55
+ //? Only the extension is taken from the URL: unzip and a Windows PATH lookup read it, and a decoded name could carry
56
+ //? a separator or `..` out of the cache folder.
57
+ static downloadName(url: string) {
58
+ const extension = /(?:\.tar)?\.[A-Za-z0-9]{1,8}$/.exec(path.posix.basename(new URL(url).pathname))?.[0] ?? "";
59
+ return `download${extension.toLowerCase()}`;
60
+ }
61
+
62
+ static extractCommand(archive: string, dir: string, platform: NodeJS.Platform = process.platform): string[] {
63
+ if (platform === "linux" && /\.zip$/i.test(archive)) return ["unzip", "-q", "-o", archive, "-d", dir];
64
+ //? Windows' own bsdtar reads zip, and a GNU tar ahead of it on PATH (Git's) takes `C:` for a remote host.
65
+ const tar =
66
+ platform === "win32" ? path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe") : "tar";
67
+ return [tar, "-xf", archive, "-C", dir];
68
+ }
69
+
70
+ static async sha256(file: string) {
71
+ return new Bun.CryptoHasher("sha256").update(await Bun.file(file).arrayBuffer()).digest("hex");
72
+ }
73
+
74
+ get #cacheDir() {
75
+ return path.join(this.app.cwdPath, ".akan", "cache", "bin");
76
+ }
77
+
78
+ /** Copies each chosen executable into `dir`, emptied first, and answers the names it carries. */
79
+ async stage(dir: string): Promise<string[]> {
80
+ const scanInfo = this.app.getScanInfo({ allowEmpty: true }) ?? (await this.app.scan({ write: false }));
81
+ const chosen = DesktopBin.select(this.config, scanInfo.getLibs());
82
+ if (!chosen.size) return [];
83
+ const platform = DesktopBin.platform();
84
+ if (!platform)
85
+ throw new Error(
86
+ `bin: this computer is ${process.platform}-${process.arch}, and a bin names only ${binPlatforms.join(", ")}.`,
87
+ );
88
+ await rm(dir, { recursive: true, force: true });
89
+ await mkdir(dir, { recursive: true });
90
+ for (const [name, { owner, platforms }] of chosen) {
91
+ const source = platforms[platform];
92
+ if (!source)
93
+ throw new Error(
94
+ `${owner}: bin.${name} names no ${platform} file, the platform this desktop app is built for (it names ${Object.keys(platforms).join(", ") || "none"}).`,
95
+ );
96
+ const executable = await this.#executable(`${owner}: bin.${name}.${platform}`, source);
97
+ const target = path.join(dir, DesktopBin.fileName(name, executable));
98
+ await Bun.write(target, Bun.file(executable));
99
+ await chmod(target, 0o755);
100
+ this.app.logger.info(`The desktop app carries ${name} from ${"url" in source ? source.url : source.path}`);
101
+ }
102
+ return [...chosen.keys()];
103
+ }
104
+
105
+ async #executable(at: string, source: AkanBinSource): Promise<string> {
106
+ const file = "url" in source ? await this.#download(at, source) : source.path;
107
+ if (!(await Bun.file(file).exists())) throw new Error(`${at}: ${file} does not exist`);
108
+ if (!source.file) return file;
109
+ const key = "url" in source ? source.sha256 : await DesktopBin.sha256(file);
110
+ const executable = path.join(await this.#extract(file, key), ...source.file.split("/"));
111
+ if (!(await Bun.file(executable).exists())) throw new Error(`${at}: the archive holds no ${source.file}`);
112
+ return executable;
113
+ }
114
+
115
+ async #download(at: string, { url, sha256 }: AkanBinUrlSource): Promise<string> {
116
+ const dir = path.join(this.#cacheDir, sha256);
117
+ const file = path.join(dir, DesktopBin.downloadName(url));
118
+ if (await Bun.file(file).exists()) return file;
119
+ await mkdir(dir, { recursive: true });
120
+ this.app.logger.info(`Downloading ${url}`);
121
+ const res = await fetch(url, { signal: AbortSignal.timeout(600_000) });
122
+ if (!res.ok) throw new Error(`${at}: ${url} answered ${res.status}`);
123
+ const bytes = new Uint8Array(await res.arrayBuffer());
124
+ const digest = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
125
+ if (digest !== sha256) throw new Error(`${at}: ${url} hashes to ${digest}, not the declared ${sha256}`);
126
+ await Bun.write(`${file}.partial`, bytes);
127
+ await rename(`${file}.partial`, file);
128
+ return file;
129
+ }
130
+
131
+ async #extract(archive: string, key: string): Promise<string> {
132
+ const dir = path.join(this.#cacheDir, key, "extracted");
133
+ if (await Bun.file(`${dir}.done`).exists()) return dir;
134
+ await rm(dir, { recursive: true, force: true });
135
+ await mkdir(dir, { recursive: true });
136
+ const [command = "tar", ...args] = DesktopBin.extractCommand(archive, dir);
137
+ if (command === "unzip" && !Bun.which("unzip"))
138
+ throw new Error(`${archive} is a zip, and this computer has no unzip: install it, or use a .tar.gz or .tar.xz.`);
139
+ await this.app.spawn(command, args, { cwd: dir });
140
+ await writeFile(`${dir}.done`, "");
141
+ return dir;
142
+ }
143
+ }