@akanjs/devkit 3.0.0-beta.26 → 3.0.0-beta.28
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.ts +22 -2
- package/akanConfig/akanBin.test.ts +62 -0
- package/akanConfig/akanBin.ts +78 -0
- package/akanConfig/akanConfig.test.ts +212 -2
- package/akanConfig/akanConfig.ts +93 -26
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +7 -1
- package/applicationBuildRunner.test.ts +36 -2
- package/applicationBuildRunner.ts +21 -6
- package/biome.base.json +3 -1
- package/csrE2e/csrE2eHarness.fixture.ts +55 -5
- package/csrE2e/csrPageStack.e2e.test.ts +65 -0
- package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
- package/csrE2e/registryVendor.e2e.test.ts +221 -0
- package/executors.test.ts +43 -0
- package/executors.ts +17 -2
- package/frontendBuild/csrDevArtifactWriter.test.ts +8 -0
- package/frontendBuild/csrDevArtifactWriter.ts +4 -0
- package/frontendBuild/csrDevBundler.ts +1 -1
- package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
- package/frontendBuild/csrDevFactoryCheck.ts +39 -0
- package/frontendBuild/csrDevModuleCompiler.test.ts +210 -6
- package/frontendBuild/csrDevModuleCompiler.ts +133 -19
- package/frontendBuild/csrDevPaths.ts +1 -0
- package/frontendBuild/csrDevResolver.ts +89 -9
- package/frontendBuild/ssrBaseArtifactBuilder.ts +7 -2
- package/frontendBuild/ssrDevBundler.ts +1 -1
- package/integration/devStability.integration.test.ts +35 -0
- package/mobile/desktopBin.test.ts +177 -0
- package/mobile/desktopBin.ts +143 -0
- package/mobile/desktopServerStage.test.ts +129 -0
- package/mobile/desktopServerStage.ts +120 -0
- package/mobile/index.ts +3 -0
- package/mobile/mobileTarget.test.ts +50 -1
- package/mobile/mobileTarget.ts +20 -4
- package/mobile/nativeApp.test.ts +37 -1
- package/mobile/nativeApp.ts +176 -27
- package/mobile/nativeAppLine.test.ts +69 -0
- package/mobile/nativeAppLine.ts +71 -0
- package/mobile/nativeConfig.test.ts +215 -4
- package/mobile/nativeConfig.ts +117 -8
- package/mobile/nativePluginFolders.test.ts +81 -0
- package/mobile/nativePluginFolders.ts +60 -0
- package/package.json +2 -2
- package/transforms/serverEnvPlugin.test.ts +100 -0
- package/transforms/serverEnvPlugin.ts +41 -0
- package/types.ts +1 -0
- package/workspaceLayout.test.ts +5 -0
- package/workspaceLayout.ts +2 -0
|
@@ -16,7 +16,7 @@ import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
|
|
|
16
16
|
//* (the app's client runtime) and every `"use client"` entry; React and the akanjs vendor facets stay the import map's,
|
|
17
17
|
//* so the page keeps one React and one store registry.
|
|
18
18
|
export class SsrDevBundler extends DevRegistryBundler {
|
|
19
|
-
static readonly #formatVersion =
|
|
19
|
+
static readonly #formatVersion = 2;
|
|
20
20
|
static readonly bootstrapKey = "";
|
|
21
21
|
readonly reloadsOnEntryChange = false;
|
|
22
22
|
#discovery: ClientEntryDiscovery | null = null;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AkanAppConfig } from "../akanConfig";
|
|
5
|
+
import { tempDirs } from "../testHelpers";
|
|
6
|
+
import type { PackageJson } from "../types";
|
|
7
|
+
import { DesktopServerStage } from "./desktopServerStage";
|
|
8
|
+
import { NativeApi } from "./nativeApi";
|
|
9
|
+
import { NativeApp } from "./nativeApp";
|
|
10
|
+
import { NativeConfig } from "./nativeConfig";
|
|
11
|
+
|
|
12
|
+
const makeTempRoot = tempDirs("akan-desktop-server-");
|
|
13
|
+
const repoApp = path.resolve(import.meta.dir, "../../../../apps/minimal");
|
|
14
|
+
const baseDevEnv = {
|
|
15
|
+
repoName: "akanjs",
|
|
16
|
+
serveDomain: "akanjs.com",
|
|
17
|
+
env: "debug" as const,
|
|
18
|
+
portOffset: 0,
|
|
19
|
+
workspaceRoot: "/w",
|
|
20
|
+
};
|
|
21
|
+
const rootPackageJson: PackageJson = {
|
|
22
|
+
name: "repo",
|
|
23
|
+
version: "1.0.0",
|
|
24
|
+
description: "repo",
|
|
25
|
+
dependencies: { "@external/runtime": "2.0.0", pg: "8.0.0" },
|
|
26
|
+
};
|
|
27
|
+
const appConfig = (config: object = {}) =>
|
|
28
|
+
new AkanAppConfig({ name: "portal" } as never, [], rootPackageJson, config, baseDevEnv);
|
|
29
|
+
|
|
30
|
+
describe("DesktopServerStage", () => {
|
|
31
|
+
test("starts from the image's env, as an API-only edge server on SQLite that the launcher gives a port", () => {
|
|
32
|
+
const env = DesktopServerStage.env(appConfig({ database: { modes: ["single", "cluster"] } }), "main");
|
|
33
|
+
|
|
34
|
+
expect(env).toMatchObject({
|
|
35
|
+
NODE_ENV: "production",
|
|
36
|
+
AKAN_PUBLIC_APP_NAME: "portal",
|
|
37
|
+
AKAN_PUBLIC_ENV: "main",
|
|
38
|
+
AKAN_PUBLIC_OPERATION_MODE: "edge",
|
|
39
|
+
AKAN_DATABASE_MODE: "single",
|
|
40
|
+
AKAN_DATABASE_MODES: "single",
|
|
41
|
+
AKAN_SSR: "false",
|
|
42
|
+
AKAN_CSR: "false",
|
|
43
|
+
AKAN_MCP: "false",
|
|
44
|
+
AKAN_SHUTDOWN_TIMEOUT_MS: "1000",
|
|
45
|
+
});
|
|
46
|
+
expect(env.PORT).toBeUndefined();
|
|
47
|
+
expect(env.AKAN_LOG_TO_FILE).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("installs what the image does, minus the RSC renderer and the other modes' drivers", () => {
|
|
51
|
+
const config = appConfig({ database: { modes: ["single", "cluster"] }, externalLibs: ["@external/runtime"] });
|
|
52
|
+
const built = config.getProductionPackageJson();
|
|
53
|
+
const drivers = config.getDatabaseModeRuntimePackages("cluster");
|
|
54
|
+
|
|
55
|
+
const { dependencies } = DesktopServerStage.packageJson(config, built);
|
|
56
|
+
|
|
57
|
+
expect(Object.keys(dependencies ?? {}).sort()).toEqual(["@external/runtime", "react", "react-dom"]);
|
|
58
|
+
for (const driver of drivers) expect(Object.keys(built.dependencies ?? {})).toContain(driver);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("keeps the image's trusted packages, so their install scripts run in the app's server too", () => {
|
|
62
|
+
const config = appConfig({ trustedDependencies: ["@external/runtime"], externalLibs: ["@external/runtime"] });
|
|
63
|
+
expect(DesktopServerStage.packageJson(config, config.getProductionPackageJson()).trustedDependencies).toEqual([
|
|
64
|
+
"@external/runtime",
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("says what the image installs that the desktop app's server goes without", () => {
|
|
69
|
+
const notice = (docker: unknown, carried: string[] = []) =>
|
|
70
|
+
DesktopServerStage.imageStepsNotice({ app: { name: "portal" }, docker } as never, carried);
|
|
71
|
+
const steps = { image: "oven/bun", preRuns: ["apt-get install -y ffmpeg"], postRuns: [], command: [] };
|
|
72
|
+
expect(notice({ ...steps, preRuns: [] })).toBeNull();
|
|
73
|
+
expect(notice(steps)).toEqual({
|
|
74
|
+
level: "warn",
|
|
75
|
+
message: expect.stringContaining(
|
|
76
|
+
"The image runs 1 docker step from the app and its libs, and a desktop app's server runs none of them",
|
|
77
|
+
),
|
|
78
|
+
});
|
|
79
|
+
expect(notice("FROM ros:humble-ros-core")?.message).toStartWith(
|
|
80
|
+
"apps/portal/akan.config.ts writes its own Dockerfile",
|
|
81
|
+
);
|
|
82
|
+
expect(notice(steps, ["ffmpeg"])).toEqual({
|
|
83
|
+
level: "info",
|
|
84
|
+
message: expect.stringContaining("carries only bin: ffmpeg"),
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("refuses an app whose database modes leave out single", () => {
|
|
89
|
+
expect(() => DesktopServerStage.assertCarriable(appConfig({ database: { modes: ["cluster"] } }))).toThrow(
|
|
90
|
+
"only database mode single runs",
|
|
91
|
+
);
|
|
92
|
+
expect(() =>
|
|
93
|
+
DesktopServerStage.assertCarriable(appConfig({ database: { modes: ["single", "cluster"] } })),
|
|
94
|
+
).not.toThrow();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("the runtime accepts a desktop config that carries the server, with single-instance on", async () => {
|
|
98
|
+
const root = await makeTempRoot();
|
|
99
|
+
await mkdir(path.join(root, "web"), { recursive: true });
|
|
100
|
+
await writeFile(path.join(root, "web/index.html"), "<html><head></head><body></body></html>");
|
|
101
|
+
await mkdir(path.join(root, "server"), { recursive: true });
|
|
102
|
+
await writeFile(path.join(root, "server/main.js"), "");
|
|
103
|
+
const server = { dir: path.join(root, "server"), entry: "main.js", env: { AKAN_PUBLIC_ENV: "main" } };
|
|
104
|
+
|
|
105
|
+
const { config } = NativeConfig.build({
|
|
106
|
+
appPath: root,
|
|
107
|
+
target: { name: "default", appName: "Portal", appId: "com.portal.app", version: "1.0.0", buildNum: 1 },
|
|
108
|
+
webDir: path.join(root, "web"),
|
|
109
|
+
contributions: [],
|
|
110
|
+
locales: ["en"],
|
|
111
|
+
platform: "macos",
|
|
112
|
+
desktopServer: server,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(config.plugins).toContain("single-instance");
|
|
116
|
+
expect(config.desktop).toEqual({ server });
|
|
117
|
+
expect((await NativeApi.load(repoApp)).validateConfig(config, { appDir: root })).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a phone build cannot carry the server", async () => {
|
|
121
|
+
const app = new NativeApp({ cwdPath: "/repo/apps/portal" } as never, {
|
|
122
|
+
name: "default",
|
|
123
|
+
config: { name: "default", appName: "Portal", appId: "com.portal.app", version: "1.0.0", buildNum: 1 },
|
|
124
|
+
});
|
|
125
|
+
const server = { dir: "/tmp/server", entry: "main.js", env: {} };
|
|
126
|
+
await expect(app.build("ios", { server })).rejects.toThrow("Only a desktop app carries its server");
|
|
127
|
+
await expect(app.run("android", { server })).rejects.toThrow("Only a desktop app carries its server");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { AkanAppConfig, MobileEnv } from "../akanConfig";
|
|
4
|
+
import type { App } from "../commandDecorators";
|
|
5
|
+
import type { PackageJson } from "../types";
|
|
6
|
+
import { DesktopBin } from "./desktopBin";
|
|
7
|
+
|
|
8
|
+
/** What a desktop build hands the native runtime as `desktop.server`. */
|
|
9
|
+
export interface DesktopServerBundle {
|
|
10
|
+
dir: string;
|
|
11
|
+
entry: string;
|
|
12
|
+
env: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
//* A desktop app's carried server is the backend `akan build` wrote into dist, installed on its own as the image's
|
|
16
|
+
//* `bun install --production` does. It is staged outside dist: installing there would put host packages into the
|
|
17
|
+
//* image's `COPY . .`.
|
|
18
|
+
export class DesktopServerStage {
|
|
19
|
+
//? The RSC worker's and the akan console's bundles: an API-only server starts neither.
|
|
20
|
+
static readonly skippedFiles = new Set(["rscWorker.js", "console.js", "console-runtime.js"]);
|
|
21
|
+
//? Only the RSC worker loads it, and its peer webpack makes 35 of the 43 MB an install would add.
|
|
22
|
+
static readonly rscRenderer = "react-server-dom-webpack";
|
|
23
|
+
|
|
24
|
+
readonly dir: string;
|
|
25
|
+
|
|
26
|
+
constructor(readonly app: App) {
|
|
27
|
+
this.dir = path.join(app.cwdPath, ".akan", "desktop", "server");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static assertCarriable(config: AkanAppConfig) {
|
|
31
|
+
if (!config.database.modes.includes("single"))
|
|
32
|
+
throw new Error(
|
|
33
|
+
`native.desktop.server puts the server in the app, where only database mode single runs (no Redis or Postgres); apps/${config.app.name}/akan.config.ts declares ${config.database.modes.join(", ")}.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static env(config: AkanAppConfig, environment: MobileEnv): Record<string, string> {
|
|
38
|
+
//? The launcher picks the port, and the log file stays on: a user's computer has no log collector.
|
|
39
|
+
const { PORT: _port, AKAN_LOG_TO_FILE: _fileLog, ...image } = config.getProductionEnv(environment);
|
|
40
|
+
return {
|
|
41
|
+
...image,
|
|
42
|
+
//? Neither the developer's machine (local, which derives a forgeable JWT secret) nor a deployment (cloud).
|
|
43
|
+
AKAN_PUBLIC_OPERATION_MODE: "edge",
|
|
44
|
+
AKAN_DATABASE_MODE: "single",
|
|
45
|
+
AKAN_DATABASE_MODES: "single",
|
|
46
|
+
//? API only: the shell serves the CSR bundle, and an RSC worker would need a bun on the user's PATH.
|
|
47
|
+
AKAN_SSR: "false",
|
|
48
|
+
AKAN_CSR: "false",
|
|
49
|
+
AKAN_MCP: "false",
|
|
50
|
+
//? Ahead of the launcher's 1.5 s grace before it signals, which sits inside the shell's 2 s quit budget.
|
|
51
|
+
AKAN_SHUTDOWN_TIMEOUT_MS: "1000",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
//? The image's steps install into a Linux image the desktop app never runs in, so its server has none of it.
|
|
56
|
+
static imageStepsNotice(
|
|
57
|
+
config: Pick<AkanAppConfig, "app" | "docker">,
|
|
58
|
+
carried: string[],
|
|
59
|
+
): { level: "warn" | "info"; message: string } | null {
|
|
60
|
+
const steps =
|
|
61
|
+
typeof config.docker === "string" ? null : config.docker.preRuns.length + config.docker.postRuns.length;
|
|
62
|
+
if (steps === 0) return null;
|
|
63
|
+
const image =
|
|
64
|
+
steps === null
|
|
65
|
+
? `apps/${config.app.name}/akan.config.ts writes its own Dockerfile`
|
|
66
|
+
: `The image runs ${steps} docker step${steps === 1 ? "" : "s"} from the app and its libs`;
|
|
67
|
+
return carried.length
|
|
68
|
+
? {
|
|
69
|
+
level: "info",
|
|
70
|
+
message: `${image}; the desktop app's server runs none of them and carries only bin: ${carried.join(", ")}.`,
|
|
71
|
+
}
|
|
72
|
+
: {
|
|
73
|
+
level: "warn",
|
|
74
|
+
message: `${image}, and a desktop app's server runs none of them: declare in bin the executables its code spawns, or those calls fail on the user's computer.`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static packageJson(config: AkanAppConfig, built: PackageJson): PackageJson {
|
|
79
|
+
const otherDrivers = new Set(
|
|
80
|
+
config.database.modes
|
|
81
|
+
.filter((mode) => mode !== "single")
|
|
82
|
+
.flatMap((mode) => config.getDatabaseModeRuntimePackages(mode)),
|
|
83
|
+
);
|
|
84
|
+
const dependencies = Object.entries(built.dependencies ?? {}).filter(
|
|
85
|
+
([name]) =>
|
|
86
|
+
config.externalLibs.includes(name) || (name !== DesktopServerStage.rscRenderer && !otherDrivers.has(name)),
|
|
87
|
+
);
|
|
88
|
+
return { ...built, dependencies: Object.fromEntries(dependencies) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async prepare(environment: MobileEnv): Promise<DesktopServerBundle> {
|
|
92
|
+
const config = await this.app.getConfig();
|
|
93
|
+
DesktopServerStage.assertCarriable(config);
|
|
94
|
+
const dist = this.app.dist.cwdPath;
|
|
95
|
+
await rm(this.dir, { recursive: true, force: true });
|
|
96
|
+
await mkdir(this.dir, { recursive: true });
|
|
97
|
+
const entries = await readdir(dist, { withFileTypes: true });
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
const carried =
|
|
100
|
+
(entry.isFile() &&
|
|
101
|
+
((entry.name.endsWith(".js") && !DesktopServerStage.skippedFiles.has(entry.name)) ||
|
|
102
|
+
entry.name === "akan.build.json")) ||
|
|
103
|
+
(entry.isDirectory() && entry.name === "private");
|
|
104
|
+
if (carried) await cp(path.join(dist, entry.name), path.join(this.dir, entry.name), { recursive: true });
|
|
105
|
+
}
|
|
106
|
+
const built = (await Bun.file(path.join(dist, "package.json")).json()) as PackageJson;
|
|
107
|
+
await writeFile(
|
|
108
|
+
path.join(this.dir, "package.json"),
|
|
109
|
+
JSON.stringify(DesktopServerStage.packageJson(config, built), null, 2),
|
|
110
|
+
);
|
|
111
|
+
await this.app.spawn(process.execPath, ["install", "--production"], { cwd: this.dir });
|
|
112
|
+
//? Launcher links only: a link out of the app bundle breaks its signature, and the server runs none of them.
|
|
113
|
+
await rm(path.join(this.dir, "node_modules", ".bin"), { recursive: true, force: true });
|
|
114
|
+
const scanInfo = this.app.getScanInfo({ allowEmpty: true }) ?? (await this.app.scan({ write: false }));
|
|
115
|
+
const carried = [...DesktopBin.select(config, scanInfo.getLibs()).keys()];
|
|
116
|
+
const notice = DesktopServerStage.imageStepsNotice(config, carried);
|
|
117
|
+
if (notice) this.app.logger[notice.level](notice.message);
|
|
118
|
+
return { dir: this.dir, entry: "main.js", env: DesktopServerStage.env(config, environment) };
|
|
119
|
+
}
|
|
120
|
+
}
|
package/mobile/index.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
export * from "./desktopBin";
|
|
2
|
+
export * from "./desktopServerStage";
|
|
1
3
|
export * from "./mobileTarget";
|
|
2
4
|
export * from "./nativeApi";
|
|
3
5
|
export * from "./nativeApp";
|
|
4
6
|
export * from "./nativeConfig";
|
|
7
|
+
export * from "./nativePluginFolders";
|
|
5
8
|
export * from "./nativeWebDir";
|
|
6
9
|
export * from "./placeholderAppId";
|
|
7
10
|
export * from "./usageDescriptions";
|