@akanjs/devkit 3.0.0-beta.27 → 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/csrPageStack.e2e.test.ts +65 -0
- package/executors.test.ts +43 -0
- package/executors.ts +17 -2
- package/frontendBuild/csrDevModuleCompiler.test.ts +32 -0
- package/frontendBuild/csrDevModuleCompiler.ts +45 -2
- package/frontendBuild/ssrBaseArtifactBuilder.ts +7 -2
- 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 +22 -1
- package/mobile/nativeApp.ts +123 -14
- package/mobile/nativeAppLine.test.ts +7 -0
- package/mobile/nativeAppLine.ts +8 -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
|
@@ -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";
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { AkanMobileTargetConfig } from "../akanConfig";
|
|
3
3
|
import type { App } from "../commandDecorators";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
appIdsOf,
|
|
6
|
+
getMobileTargetChoices,
|
|
7
|
+
resolveAppId,
|
|
8
|
+
resolveMobilePath,
|
|
9
|
+
resolveMobileTargets,
|
|
10
|
+
targetHtmlFilename,
|
|
11
|
+
} from "./mobileTarget";
|
|
5
12
|
|
|
6
13
|
const target = {
|
|
7
14
|
name: "akanjs",
|
|
@@ -19,6 +26,17 @@ describe("mobile target helpers", () => {
|
|
|
19
26
|
expect(resolveMobilePath({ ...target, basePath: undefined }, "/order/123")).toBe("/order/123");
|
|
20
27
|
});
|
|
21
28
|
|
|
29
|
+
test("an app id per platform falls back to default, and a platform with neither is refused", () => {
|
|
30
|
+
const appId = { default: "com.yeollege", ios: "com.puffinplanet.yeollege" };
|
|
31
|
+
expect(resolveAppId(appId, "ios")).toBe("com.puffinplanet.yeollege");
|
|
32
|
+
expect(resolveAppId(appId, "android")).toBe("com.yeollege");
|
|
33
|
+
expect(resolveAppId("com.one.app", "macos")).toBe("com.one.app");
|
|
34
|
+
expect(() => resolveAppId({ ios: "com.puffinplanet.yeollege" }, "android")).toThrow(
|
|
35
|
+
"mobile.appId names no id for android",
|
|
36
|
+
);
|
|
37
|
+
expect(appIdsOf(appId)).toEqual(["com.yeollege", "com.puffinplanet.yeollege"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
22
40
|
test("selects the self-contained html file for a target", () => {
|
|
23
41
|
expect(targetHtmlFilename(target)).toBe("akanjs.html");
|
|
24
42
|
expect(targetHtmlFilename({ ...target, basePath: undefined })).toBe("index.html");
|
|
@@ -36,6 +54,37 @@ describe("mobile target helpers", () => {
|
|
|
36
54
|
await expect(resolveMobileTargets(app, undefined)).resolves.toEqual([{ name: "akanjs", config: target }]);
|
|
37
55
|
});
|
|
38
56
|
|
|
57
|
+
test("a target naming no basePath is a template in an app with basePaths, never a page of its own", async () => {
|
|
58
|
+
const template = { ...target, name: "default", basePath: undefined };
|
|
59
|
+
const appWith = (basePaths: string[]) =>
|
|
60
|
+
({
|
|
61
|
+
name: "angelo",
|
|
62
|
+
getConfig: async () => ({ basePaths: new Set(basePaths), mobile: { targets: { default: template } } }),
|
|
63
|
+
}) as unknown as App;
|
|
64
|
+
|
|
65
|
+
const two = appWith(["soft", "office"]);
|
|
66
|
+
await expect(getMobileTargetChoices(two)).resolves.toEqual(["soft", "office"]);
|
|
67
|
+
await expect(resolveMobileTargets(two, undefined)).rejects.toThrow(
|
|
68
|
+
"Multiple mobile targets found for angelo. Pass --target <soft|office|all>.",
|
|
69
|
+
);
|
|
70
|
+
await expect(resolveMobileTargets(two, "office")).resolves.toEqual([
|
|
71
|
+
{ name: "office", config: { ...template, name: "office", basePath: "office" } },
|
|
72
|
+
]);
|
|
73
|
+
await expect(resolveMobileTargets(two, "all")).resolves.toEqual([
|
|
74
|
+
{ name: "soft", config: { ...template, name: "soft", basePath: "soft" } },
|
|
75
|
+
{ name: "office", config: { ...template, name: "office", basePath: "office" } },
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
const one = appWith(["soft"]);
|
|
79
|
+
const soft = [{ name: "soft", config: { ...template, name: "soft", basePath: "soft" } }];
|
|
80
|
+
await expect(resolveMobileTargets(one, undefined)).resolves.toEqual(soft);
|
|
81
|
+
await expect(resolveMobileTargets(one, "all")).resolves.toEqual(soft);
|
|
82
|
+
|
|
83
|
+
await expect(resolveMobileTargets(appWith([]), undefined)).resolves.toEqual([
|
|
84
|
+
{ name: "default", config: template },
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
39
88
|
test("resolves a route base path onto the default mobile target config", async () => {
|
|
40
89
|
const app = {
|
|
41
90
|
getConfig: async () => ({
|
package/mobile/mobileTarget.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AkanMobileAppId } from "akanjs";
|
|
2
|
+
import type { AkanAppConfig, AkanMobileTargetConfig, MobileEnv } from "../akanConfig";
|
|
2
3
|
import type { App } from "../commandDecorators";
|
|
3
4
|
|
|
4
5
|
export type MobilePlatform = "ios" | "android";
|
|
@@ -20,10 +21,15 @@ export const getMobileTargets = async (app: App): Promise<ResolvedMobileTarget[]
|
|
|
20
21
|
return Object.entries(config.mobile.targets).map(([name, target]) => ({ name, config: target }));
|
|
21
22
|
};
|
|
22
23
|
|
|
24
|
+
//* An app with basePaths builds a CSR page per basePath and none at its root, so a target naming no basePath opens
|
|
25
|
+
//* nothing on its own there; it is the template each basePath's target is made from.
|
|
26
|
+
const isTemplateOnly = ({ basePaths, mobile }: Pick<AkanAppConfig, "basePaths" | "mobile">) =>
|
|
27
|
+
basePaths.size > 0 && Object.values(mobile.targets).every((target) => !target.basePath);
|
|
28
|
+
|
|
23
29
|
export const getMobileTargetChoices = async (app: App): Promise<string[]> => {
|
|
24
30
|
const config = await app.getConfig();
|
|
25
31
|
const targetNames = Object.keys(config.mobile.targets);
|
|
26
|
-
if (targetNames.length > 0) return targetNames;
|
|
32
|
+
if (targetNames.length > 0 && !isTemplateOnly(config)) return targetNames;
|
|
27
33
|
return [...config.basePaths];
|
|
28
34
|
};
|
|
29
35
|
|
|
@@ -51,7 +57,7 @@ export const resolveMobileTargets = async (
|
|
|
51
57
|
const config = await app.getConfig();
|
|
52
58
|
const targets = await getMobileTargets(app);
|
|
53
59
|
if (targets.length === 0) throw new Error(`No mobile targets configured for ${app.name}`);
|
|
54
|
-
if (!selection && targets.length === 1) return targets;
|
|
60
|
+
if (!selection && targets.length === 1 && !isTemplateOnly(config)) return targets;
|
|
55
61
|
if (!selection) {
|
|
56
62
|
const choices = await getMobileTargetChoices(app);
|
|
57
63
|
if (choices.length === 1) return resolveMobileTargets(app, choices[0]);
|
|
@@ -60,7 +66,7 @@ export const resolveMobileTargets = async (
|
|
|
60
66
|
if (selection === "all") {
|
|
61
67
|
if (Object.keys(config.mobile.targets).length > 1) return targets;
|
|
62
68
|
const basePaths = [...config.basePaths];
|
|
63
|
-
if (basePaths.length > 1) {
|
|
69
|
+
if (basePaths.length > 1 || isTemplateOnly(config)) {
|
|
64
70
|
return basePaths.flatMap((basePath) => {
|
|
65
71
|
const resolved = resolveMobileTargetByBasePath(targets, basePath);
|
|
66
72
|
return resolved ? [resolved] : [];
|
|
@@ -76,6 +82,16 @@ export const resolveMobileTargets = async (
|
|
|
76
82
|
throw new Error(`Mobile target '${selection}' was not found. Available: ${choices.join(", ")}`);
|
|
77
83
|
};
|
|
78
84
|
|
|
85
|
+
export const resolveAppId = (appId: AkanMobileAppId, platform: NativePlatform): string => {
|
|
86
|
+
if (typeof appId === "string") return appId;
|
|
87
|
+
const id = appId[platform] ?? appId.default;
|
|
88
|
+
if (!id) throw new Error(`mobile.appId names no id for ${platform}; give it ${platform} or default.`);
|
|
89
|
+
return id;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const appIdsOf = (appId: AkanMobileAppId): string[] =>
|
|
93
|
+
typeof appId === "string" ? [appId] : Object.values(appId).filter((id): id is string => !!id);
|
|
94
|
+
|
|
79
95
|
export const resolveMobilePath = (target: AkanMobileTargetConfig, pathname: string) => {
|
|
80
96
|
const basePath = trimSlashes(target.basePath ?? "");
|
|
81
97
|
const normalizedPath = `/${pathname.replace(/^\/+/, "")}`;
|
package/mobile/nativeApp.test.ts
CHANGED
|
@@ -22,7 +22,10 @@ const appDir = "/repo/apps/portal";
|
|
|
22
22
|
|
|
23
23
|
const fakeApp = (plugins: AkanPlugin[] = []) =>
|
|
24
24
|
({
|
|
25
|
+
name: "portal",
|
|
25
26
|
cwdPath: appDir,
|
|
27
|
+
workspace: { workspaceRoot: "/repo" },
|
|
28
|
+
getScanInfo: () => ({ getLibs: () => [] }),
|
|
26
29
|
dist: { cwdPath: "/repo/dist/apps/portal" },
|
|
27
30
|
getConfig: async () => ({ i18n: { locales: ["en", "ko"] }, api: { prefix: "/api", websocketPrefix: "/ws" } }),
|
|
28
31
|
collectPlugins: async () => plugins,
|
|
@@ -69,13 +72,31 @@ describe("NativeApp", () => {
|
|
|
69
72
|
target({ basePath: undefined, permissions: ["push"] }),
|
|
70
73
|
);
|
|
71
74
|
|
|
72
|
-
const { config, warnings } = await app.config();
|
|
75
|
+
const { config, warnings } = await app.config({ platform: "android" });
|
|
73
76
|
|
|
74
77
|
expect(warnings).toEqual([]);
|
|
75
78
|
expect(config.plugins).toEqual([...NativeConfig.basePlugins, "push"]);
|
|
76
79
|
expect(config.web.dir).toBe(path.join(appDir, ".akan/mobile/default/web"));
|
|
77
80
|
});
|
|
78
81
|
|
|
82
|
+
test("a config nothing is built from (the update key's) stages no desktop bin", async () => {
|
|
83
|
+
const { config } = await new NativeApp(fakeApp(), target({ basePath: undefined })).config({
|
|
84
|
+
platform: "macos",
|
|
85
|
+
stageBin: false,
|
|
86
|
+
});
|
|
87
|
+
expect(config.desktop?.bin).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("follows the updates channel of the backend env it is built for", async () => {
|
|
91
|
+
const updates = { url: "https://releases.example.com/portal", publicKey: `${"a".repeat(43)}=` };
|
|
92
|
+
const built = async (env?: "debug" | "main") =>
|
|
93
|
+
(await new NativeApp(fakeApp(), target({ basePath: undefined, updates }), env).config({ platform: "android" }))
|
|
94
|
+
.config.updates;
|
|
95
|
+
|
|
96
|
+
expect(await built("main")).toEqual({ ...updates, channel: "main" });
|
|
97
|
+
expect(await built("debug")).toEqual({ ...updates, channel: "debug" });
|
|
98
|
+
});
|
|
99
|
+
|
|
79
100
|
test("a desktop app is this computer's platform", () => {
|
|
80
101
|
expect(["darwin", "win32", "linux"].map((host) => NativeApp.desktopPlatform(host as NodeJS.Platform))).toEqual([
|
|
81
102
|
"macos",
|
package/mobile/nativeApp.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { AkanNativeConfig } from "@akanjs/native/config";
|
|
4
|
+
import type { MobileEnv } from "akanjs";
|
|
4
5
|
import type { App } from "../commandDecorators";
|
|
5
6
|
import { Executor } from "../executors";
|
|
7
|
+
import { DesktopBin } from "./desktopBin";
|
|
8
|
+
import type { DesktopServerBundle } from "./desktopServerStage";
|
|
6
9
|
import {
|
|
7
10
|
type DesktopPlatform,
|
|
8
11
|
type MobilePlatform,
|
|
@@ -13,6 +16,7 @@ import {
|
|
|
13
16
|
import { NativeApi, type NativeBuildApiModule } from "./nativeApi";
|
|
14
17
|
import { NativeAppLine, type NativeAppLineRead } from "./nativeAppLine";
|
|
15
18
|
import { NativeConfig } from "./nativeConfig";
|
|
19
|
+
import { NativePluginFolders } from "./nativePluginFolders";
|
|
16
20
|
import { NativeWebDir } from "./nativeWebDir";
|
|
17
21
|
|
|
18
22
|
type TaskOptions = Parameters<NativeBuildApiModule["build"]>[0];
|
|
@@ -31,6 +35,14 @@ interface NativeDevBoot {
|
|
|
31
35
|
lines: NativeAppLineRead[];
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
export interface NativeBuildOptions {
|
|
39
|
+
profile?: "debug" | "release";
|
|
40
|
+
/** The server a desktop app carries (the target's `native.desktop.server`), staged by DesktopServerStage. */
|
|
41
|
+
server?: DesktopServerBundle;
|
|
42
|
+
/** Windows: an NSIS setup program beside the app folder. */
|
|
43
|
+
installer?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
export interface NativeDevOptions extends NativeRunOptions {
|
|
35
47
|
/** The akan dev server the gateway proxies. */
|
|
36
48
|
upstream: string;
|
|
@@ -48,6 +60,7 @@ export class NativeApp {
|
|
|
48
60
|
constructor(
|
|
49
61
|
readonly app: App,
|
|
50
62
|
readonly target: ResolvedMobileTarget,
|
|
63
|
+
readonly env?: MobileEnv,
|
|
51
64
|
) {
|
|
52
65
|
this.targetRoot = path.join(app.cwdPath, ".akan", "mobile", target.name);
|
|
53
66
|
this.web = new NativeWebDir(path.join(this.targetRoot, "web"));
|
|
@@ -67,20 +80,48 @@ export class NativeApp {
|
|
|
67
80
|
});
|
|
68
81
|
}
|
|
69
82
|
|
|
70
|
-
|
|
71
|
-
|
|
83
|
+
/** Where a desktop build stages the executables it carries (akan.config.ts `bin`). */
|
|
84
|
+
get binDir() {
|
|
85
|
+
return path.join(this.targetRoot, "bin");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `stageBin: false` for a config nothing is built from (the update key's): staging downloads the `bin` sources. */
|
|
89
|
+
async config({
|
|
90
|
+
server,
|
|
91
|
+
platform,
|
|
92
|
+
stageBin = true,
|
|
93
|
+
}: {
|
|
94
|
+
server?: DesktopServerBundle;
|
|
95
|
+
platform: NativePlatform;
|
|
96
|
+
stageBin?: boolean;
|
|
97
|
+
}) {
|
|
98
|
+
const [appConfig, plugins, nativePlugins] = await Promise.all([
|
|
99
|
+
this.app.getConfig(),
|
|
100
|
+
this.app.collectPlugins(),
|
|
101
|
+
NativePluginFolders.of(this.app),
|
|
102
|
+
]);
|
|
103
|
+
const desktop = platform === "macos" || platform === "windows" || platform === "linux";
|
|
104
|
+
const carried = desktop && stageBin ? await new DesktopBin(this.app, appConfig).stage(this.binDir) : [];
|
|
72
105
|
return NativeConfig.build({
|
|
73
106
|
appPath: this.app.cwdPath,
|
|
74
107
|
target: this.target.config,
|
|
75
108
|
webDir: this.web.dir,
|
|
76
109
|
contributions: plugins.flatMap((plugin) => (plugin.native ? [plugin.native] : [])),
|
|
77
110
|
locales: appConfig.i18n.locales,
|
|
111
|
+
env: this.env,
|
|
112
|
+
platform,
|
|
113
|
+
nativePlugins,
|
|
114
|
+
...(server ? { desktopServer: server } : {}),
|
|
115
|
+
...(carried.length ? { desktopBin: this.binDir } : {}),
|
|
78
116
|
});
|
|
79
117
|
}
|
|
80
118
|
|
|
81
119
|
/** The API and the config it is about to build, refused here when the runtime would refuse it later. */
|
|
82
|
-
async prepare() {
|
|
83
|
-
const [api, { config, warnings }] = await Promise.all([
|
|
120
|
+
async prepare(platform: NativePlatform, server?: DesktopServerBundle) {
|
|
121
|
+
const [api, { config, warnings }] = await Promise.all([
|
|
122
|
+
NativeApi.load(this.app.cwdPath),
|
|
123
|
+
this.config({ platform, ...(server ? { server } : {}) }),
|
|
124
|
+
]);
|
|
84
125
|
for (const warning of warnings) this.app.logger.warn(warning);
|
|
85
126
|
const problems = api.validateConfig(config, { appDir: this.app.cwdPath });
|
|
86
127
|
if (problems.length)
|
|
@@ -116,18 +157,25 @@ export class NativeApp {
|
|
|
116
157
|
};
|
|
117
158
|
}
|
|
118
159
|
|
|
119
|
-
async build(platform: NativePlatform, { profile = "release"
|
|
160
|
+
async build(platform: NativePlatform, { profile = "release", server, installer = false }: NativeBuildOptions = {}) {
|
|
161
|
+
NativeApp.#assertServerPlatform(platform, server);
|
|
162
|
+
if (installer && platform !== "windows") throw new Error(`An installer is built for Windows, not for ${platform}.`);
|
|
120
163
|
await this.assembleWeb();
|
|
121
|
-
const { api, config } = await this.prepare();
|
|
122
|
-
return await api.build({
|
|
164
|
+
const { api, config } = await this.prepare(platform, server);
|
|
165
|
+
return await api.build({
|
|
166
|
+
...this.#task(platform, config),
|
|
167
|
+
profile,
|
|
168
|
+
...(installer ? { windows: { installer } } : {}),
|
|
169
|
+
});
|
|
123
170
|
}
|
|
124
171
|
|
|
125
172
|
async run(
|
|
126
173
|
platform: NativePlatform,
|
|
127
|
-
{ device, teamId, profile = "debug" }: NativeRunOptions &
|
|
174
|
+
{ device, teamId, profile = "debug", server }: NativeRunOptions & NativeBuildOptions = {},
|
|
128
175
|
) {
|
|
176
|
+
NativeApp.#assertServerPlatform(platform, server);
|
|
129
177
|
await this.assembleWeb();
|
|
130
|
-
const { api, config } = await this.prepare();
|
|
178
|
+
const { api, config } = await this.prepare(platform, server);
|
|
131
179
|
return await api.run({
|
|
132
180
|
...this.#task(platform, config),
|
|
133
181
|
profile,
|
|
@@ -137,21 +185,22 @@ export class NativeApp {
|
|
|
137
185
|
});
|
|
138
186
|
}
|
|
139
187
|
|
|
140
|
-
//* The gateway serves the page
|
|
141
|
-
//*
|
|
188
|
+
//* The gateway serves the page, its HMR socket and its API calls on the app origin. The dev server's own port is
|
|
189
|
+
//* reversed too: a browser the app hands off to (auth-session) opens it directly (baseEnv getServerOrigin).
|
|
142
190
|
async dev(platform: NativePlatform, { upstream, lang, device, teamId }: NativeDevOptions) {
|
|
143
191
|
const startedAt = performance.now();
|
|
144
192
|
const boot: NativeDevBoot = { steps: [], lines: [] };
|
|
145
193
|
this.#boot = boot;
|
|
146
194
|
try {
|
|
147
195
|
await mkdir(this.web.dir, { recursive: true });
|
|
148
|
-
const [{ api, config }, { api: routes }] = await Promise.all([this.prepare(), this.app.getConfig()]);
|
|
196
|
+
const [{ api, config }, { api: routes }] = await Promise.all([this.prepare(platform), this.app.getConfig()]);
|
|
149
197
|
const session = await api.dev({
|
|
150
198
|
...this.#task(platform, config),
|
|
151
199
|
upstream,
|
|
152
200
|
hmrPath: "/_akan/hmr",
|
|
153
201
|
//? A dev page calls its own origin (akanjs baseEnv), so the gateway relays the socket it opens for the API too.
|
|
154
202
|
wsPaths: [`${routes.prefix}${routes.websocketPrefix}`],
|
|
203
|
+
reversePorts: [Number(new URL(upstream).port)],
|
|
155
204
|
onLine: this.#appLine,
|
|
156
205
|
startPath: this.startPath(lang),
|
|
157
206
|
...(device ? { device } : {}),
|
|
@@ -185,7 +234,7 @@ export class NativeApp {
|
|
|
185
234
|
|
|
186
235
|
async releaseIos({ teamId, adHoc = false }: { teamId?: string; adHoc?: boolean } = {}) {
|
|
187
236
|
await this.assembleWeb();
|
|
188
|
-
const { api, config } = await this.prepare();
|
|
237
|
+
const { api, config } = await this.prepare("ios");
|
|
189
238
|
return await api.release({
|
|
190
239
|
...this.#task("ios", config),
|
|
191
240
|
platform: "ios",
|
|
@@ -193,13 +242,67 @@ export class NativeApp {
|
|
|
193
242
|
});
|
|
194
243
|
}
|
|
195
244
|
|
|
245
|
+
/** Where publishUpdate writes: upload this folder as the target's updates.url. */
|
|
246
|
+
get updatesDir() {
|
|
247
|
+
return path.join(this.targetRoot, "updates");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A signed release installed apps take through the updates plugin, from a release build of this bundle. */
|
|
251
|
+
async publishUpdate(
|
|
252
|
+
platform: NativePlatform,
|
|
253
|
+
{ channel, server }: { channel?: string; server?: DesktopServerBundle } = {},
|
|
254
|
+
) {
|
|
255
|
+
NativeApp.#assertServerPlatform(platform, server);
|
|
256
|
+
if (!this.target.config.updates)
|
|
257
|
+
throw new Error(
|
|
258
|
+
`Mobile target '${this.target.name}' has no updates: { url, publicKey } in akan.config.ts; \`akan update-keygen ${this.app.name}\` prints the key.`,
|
|
259
|
+
);
|
|
260
|
+
await this.assembleWeb();
|
|
261
|
+
const { api, config } = await this.prepare(platform, server);
|
|
262
|
+
return await api.publishUpdate({
|
|
263
|
+
...this.#task(platform, config),
|
|
264
|
+
platform,
|
|
265
|
+
out: this.updatesDir,
|
|
266
|
+
...(channel ? { channel } : {}),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The key update releases of this target's app id on `platform` are signed with: made once, then read. */
|
|
271
|
+
async updateKeygen(platform: NativePlatform) {
|
|
272
|
+
const [api, { config }] = await Promise.all([
|
|
273
|
+
NativeApi.load(this.app.cwdPath),
|
|
274
|
+
this.config({ platform, stageBin: false }),
|
|
275
|
+
]);
|
|
276
|
+
return api.updateKeygen({ config });
|
|
277
|
+
}
|
|
278
|
+
|
|
196
279
|
async releaseAndroid({ formats = ["aab"] }: { formats?: ("aab" | "apk")[] } = {}) {
|
|
197
280
|
const signing = NativeApp.androidSigning();
|
|
198
281
|
await this.assembleWeb();
|
|
199
|
-
const { api, config } = await this.prepare();
|
|
282
|
+
const { api, config } = await this.prepare("android");
|
|
200
283
|
return await api.release({ ...this.#task("android", config), platform: "android", signing, formats });
|
|
201
284
|
}
|
|
202
285
|
|
|
286
|
+
/** An unsigned OTA update of this target's web bundle for `platform`, and the channel its binary follows. */
|
|
287
|
+
async packUpdate(platform: MobilePlatform, { out }: { out?: string } = {}) {
|
|
288
|
+
await this.assembleWeb();
|
|
289
|
+
const { api, config } = await this.prepare(platform);
|
|
290
|
+
if (!config.updates)
|
|
291
|
+
throw new Error(
|
|
292
|
+
`Mobile target '${this.target.name}' takes no updates: add mobile.updates: { url, publicKey } to akan.config.ts first.`,
|
|
293
|
+
);
|
|
294
|
+
const packed = await api.packUpdate({
|
|
295
|
+
...this.#task(platform, config),
|
|
296
|
+
platform,
|
|
297
|
+
out: out ?? path.join(this.targetRoot, "updates", platform),
|
|
298
|
+
});
|
|
299
|
+
return { ...packed, channel: config.updates.channel ?? "production" };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
static async compareBundles(appDir: string, shipped: string, bundle: string) {
|
|
303
|
+
return (await NativeApi.load(appDir)).compareBundles(shipped, bundle);
|
|
304
|
+
}
|
|
305
|
+
|
|
203
306
|
//* The names the Gradle build read, so a CI that already holds these secrets keeps working.
|
|
204
307
|
static androidSigning(env: Record<string, string | undefined> = process.env): AndroidSigning {
|
|
205
308
|
const keystore = env.MYAPP_RELEASE_STORE_FILE;
|
|
@@ -232,6 +335,12 @@ export class NativeApp {
|
|
|
232
335
|
return `/${home.join("/")}?${params}`;
|
|
233
336
|
}
|
|
234
337
|
|
|
338
|
+
//? A phone runs no Bun, so only a desktop app can carry the server.
|
|
339
|
+
static #assertServerPlatform(platform: NativePlatform, server: DesktopServerBundle | undefined) {
|
|
340
|
+
if (server && (platform === "ios" || platform === "android"))
|
|
341
|
+
throw new Error(`Only a desktop app carries its server; ${platform} cannot run one.`);
|
|
342
|
+
}
|
|
343
|
+
|
|
235
344
|
//* A desktop app builds only on its own OS, so the platform is this computer's.
|
|
236
345
|
static desktopPlatform(host: NodeJS.Platform = process.platform): DesktopPlatform {
|
|
237
346
|
if (host === "darwin") return "macos";
|
|
@@ -24,6 +24,13 @@ describe("NativeAppLine", () => {
|
|
|
24
24
|
});
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
+
test("a carried server's line keeps the level its logger wrote, and one without a level stays visible", () => {
|
|
28
|
+
const line = "[server] [minimal] 81 - 09/30/2026, 12:30:06 PM ERROR boom +2ms";
|
|
29
|
+
expect(NativeAppLine.read(line)).toEqual({ level: "error", message: line });
|
|
30
|
+
expect(NativeAppLine.read("[server] \u001b[32m[minimal] 81 -\u001b[39m WARN slow").level).toBe("warn");
|
|
31
|
+
expect(NativeAppLine.read("[server] at run (main.js:1:2)").level).toBe("info");
|
|
32
|
+
});
|
|
33
|
+
|
|
27
34
|
test("a second window is named, the first is not", () => {
|
|
28
35
|
expect(NativeAppLine.read("[page#2 info] [App] ready")).toEqual({ level: "info", message: "[page#2:App] ready" });
|
|
29
36
|
});
|
package/mobile/nativeAppLine.ts
CHANGED
|
@@ -13,6 +13,10 @@ export class NativeAppLine {
|
|
|
13
13
|
static readonly #page = /^\[page(\+)?(?:#(\d+))? (trace|verbose|debug|log|info|warn|error)\] ?(.*)$/;
|
|
14
14
|
static readonly #logcat = /^([VDIWEFA])\/([^(]+?)\s*\(\s*\d+\):\s?(.*)$/;
|
|
15
15
|
static readonly #named = /^\[([^\]\s]+)\] ([\s\S]*)$/;
|
|
16
|
+
//? A server the desktop app carries (host server.ts): `[server] <its own log line>`, whose level word the akan logger
|
|
17
|
+
//? wrote; a line without one (a crash's stack) is still the app's and stays visible.
|
|
18
|
+
static readonly #server = /^\[server\] /;
|
|
19
|
+
static readonly #serverLevel = /\b(TRACE|VERBOSE|DEBUG|INFO|WARN|ERROR)\b/;
|
|
16
20
|
static readonly #logcatLevels: { [priority: string]: LogLevel } = {
|
|
17
21
|
V: "verbose",
|
|
18
22
|
D: "debug",
|
|
@@ -42,6 +46,10 @@ export class NativeAppLine {
|
|
|
42
46
|
});
|
|
43
47
|
}
|
|
44
48
|
if (line.startsWith("[akan-native")) return NativeAppLine.#hostLine(line, "info");
|
|
49
|
+
if (NativeAppLine.#server.test(line)) {
|
|
50
|
+
const word = NativeAppLine.#serverLevel.exec(Bun.stripANSI(line))?.[1];
|
|
51
|
+
return { level: word ? (word.toLowerCase() as LogLevel) : "info", message: line };
|
|
52
|
+
}
|
|
45
53
|
return { level: verbose ? "info" : "debug", message: line };
|
|
46
54
|
}
|
|
47
55
|
|