@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.
- package/akanApp/akanApp.host.test.ts +55 -0
- package/akanApp/akanApp.host.ts +24 -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 +204 -0
- package/mobile/desktopServerStage.ts +138 -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 +52 -1
- package/mobile/nativeApp.ts +175 -20
- 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/mobile/nativeWebDir.ts +6 -2
- package/package.json +2 -2
- package/transforms/serverEnvPlugin.test.ts +103 -0
- package/transforms/serverEnvPlugin.ts +41 -0
- package/types.ts +1 -0
- package/workspaceLayout.test.ts +5 -0
- package/workspaceLayout.ts +2 -0
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { IncrementalBuilderHost } from "../incrementalBuilder";
|
|
4
|
+
import { createTempApp, isolateEnv, tempRoots, writeText } from "../testHelpers";
|
|
2
5
|
import { AkanAppHost } from "./akanApp.host";
|
|
6
|
+
import type { DevHostEvent } from "./devHostPolicy";
|
|
3
7
|
|
|
4
8
|
describe("readProcessRssBytes", () => {
|
|
5
9
|
test("reads this process's own rss", async () => {
|
|
@@ -14,3 +18,54 @@ describe("readProcessRssBytes", () => {
|
|
|
14
18
|
expect(await AkanAppHost.readProcessRssBytes(2_147_483_646)).toBeNull();
|
|
15
19
|
});
|
|
16
20
|
});
|
|
21
|
+
|
|
22
|
+
describe("a gateway whose replica crash-loops", () => {
|
|
23
|
+
isolateEnv({ AKAN_PUBLIC_REPO_NAME: "repo", AKAN_PUBLIC_SERVE_DOMAIN: "localhost", AKAN_PUBLIC_ENV: "local" });
|
|
24
|
+
const track = tempRoots();
|
|
25
|
+
const crashLoop = "Backend replica 0/federation failed 3 consecutive boots; waiting for a code change to retry: boom";
|
|
26
|
+
const readyBuilder = {
|
|
27
|
+
status: "ready",
|
|
28
|
+
patcherOff: false,
|
|
29
|
+
start: ({ onReady }: { onReady?: () => void }) => onReady?.(),
|
|
30
|
+
send: () => true,
|
|
31
|
+
stop: () => undefined,
|
|
32
|
+
} as unknown as IncrementalBuilderHost;
|
|
33
|
+
|
|
34
|
+
test("fails the dev server at once, and leaves it running for the fix", async () => {
|
|
35
|
+
const { root, app } = track(await createTempApp("demo"));
|
|
36
|
+
await writeText(
|
|
37
|
+
path.join(root, "apps/demo/main.ts"),
|
|
38
|
+
[
|
|
39
|
+
`process.send?.({ type: "build-status", data: { generation: -1, phase: "backend", ok: false, files: [], message: ${JSON.stringify(crashLoop)} } });`,
|
|
40
|
+
"setInterval(() => undefined, 1_000);",
|
|
41
|
+
"",
|
|
42
|
+
].join("\n"),
|
|
43
|
+
);
|
|
44
|
+
const create = IncrementalBuilderHost.create;
|
|
45
|
+
IncrementalBuilderHost.create = async () => readyBuilder;
|
|
46
|
+
const events: DevHostEvent[] = [];
|
|
47
|
+
const failed = Promise.withResolvers<void>();
|
|
48
|
+
const host = new AkanAppHost(app, {
|
|
49
|
+
env: { ...process.env } as Record<string, string>,
|
|
50
|
+
onDevEvent: (event) => {
|
|
51
|
+
events.push(event);
|
|
52
|
+
if ("state" in event && event.state === "failed") failed.resolve();
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
let beforeStop: DevHostEvent[] = [];
|
|
56
|
+
try {
|
|
57
|
+
await host.start();
|
|
58
|
+
await failed.promise;
|
|
59
|
+
await Bun.sleep(300);
|
|
60
|
+
beforeStop = [...events];
|
|
61
|
+
} finally {
|
|
62
|
+
await host.stop();
|
|
63
|
+
IncrementalBuilderHost.create = create;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
expect(beforeStop).toEqual([
|
|
67
|
+
{ app: "demo", state: "starting" },
|
|
68
|
+
{ app: "demo", state: "failed", detail: crashLoop },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -104,6 +104,9 @@ export class AkanAppHost {
|
|
|
104
104
|
#backendGaveUp = false;
|
|
105
105
|
#backendLifecycleState: BackendLifecycleState = "stopped";
|
|
106
106
|
#pendingRestartReason: BackendRestartReason | null = null;
|
|
107
|
+
//? One replacement at a time: `#stopBackend` clears `#backend` only once the old process exits, so a second restart
|
|
108
|
+
//? inside that wait signals the same pid and spawns a second backend, and the one the host stops tracking keeps the port.
|
|
109
|
+
#backendRestart: Promise<void> | null = null;
|
|
107
110
|
#pendingRecycle: {
|
|
108
111
|
message: Extract<BuilderMessage, { type: "invalidate" }>;
|
|
109
112
|
refreshConfig: boolean;
|
|
@@ -181,8 +184,10 @@ export class AkanAppHost {
|
|
|
181
184
|
this.#cancelIdleSuspend();
|
|
182
185
|
this.#stopIdleWatcher();
|
|
183
186
|
this.#clearRestartTimers();
|
|
187
|
+
this.#pendingRestartReason = null;
|
|
184
188
|
// Before the backend goes away, while it can still receive the answer.
|
|
185
189
|
this.#failPendingBuilderMessages("dev server is shutting down");
|
|
190
|
+
await this.#backendRestart;
|
|
186
191
|
await this.#stopBackend();
|
|
187
192
|
this.#stopBuilder();
|
|
188
193
|
return this;
|
|
@@ -202,6 +207,11 @@ export class AkanAppHost {
|
|
|
202
207
|
return await createTunnel(type, { app: this.app, environment });
|
|
203
208
|
}
|
|
204
209
|
#startBackend(startStatus: { generation?: number; files: string[] } | null = null) {
|
|
210
|
+
if (this.#stopping) return;
|
|
211
|
+
if (this.#backend) {
|
|
212
|
+
this.logger.warn(`backend pid=${this.#backend.pid} is still running; not starting a second one`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
205
215
|
// Before the spawn: the new backend numbers its requests from 1 again, so old answers must not reach it.
|
|
206
216
|
this.#builderRequests.startGeneration();
|
|
207
217
|
this.#discardPendingBuilderMessages("the backend restarted while the builder was away");
|
|
@@ -235,6 +245,8 @@ export class AkanAppHost {
|
|
|
235
245
|
message: msg.data.message,
|
|
236
246
|
});
|
|
237
247
|
this.#sendOrQueueBuildStatus(status);
|
|
248
|
+
//? A replica crash loop leaves the gateway up, waiting for an edit: no exit reaches the host's own give-up.
|
|
249
|
+
if (!status.ok) this.#emitDevEvent("failed", status.message);
|
|
238
250
|
return;
|
|
239
251
|
}
|
|
240
252
|
if (backendMsgTypeSet.has(msg.type)) this.#sendToBuilder(msg);
|
|
@@ -386,10 +398,18 @@ export class AkanAppHost {
|
|
|
386
398
|
if (this.#restartTimer) clearTimeout(this.#restartTimer);
|
|
387
399
|
this.#restartTimer = setTimeout(() => {
|
|
388
400
|
this.#restartTimer = null;
|
|
401
|
+
this.#backendRestart ??= this.#drainBackendRestarts().finally(() => {
|
|
402
|
+
this.#backendRestart = null;
|
|
403
|
+
});
|
|
404
|
+
}, BACKEND_RESTART_DEBOUNCE_MS);
|
|
405
|
+
}
|
|
406
|
+
//? A reason that arrived during a restart runs after it; one whose debounce is still pending waits for its timer.
|
|
407
|
+
async #drainBackendRestarts() {
|
|
408
|
+
while (this.#pendingRestartReason && !this.#restartTimer && !this.#stopping) {
|
|
389
409
|
const next = this.#pendingRestartReason;
|
|
390
410
|
this.#pendingRestartReason = null;
|
|
391
|
-
|
|
392
|
-
}
|
|
411
|
+
await this.#restartBackend(next);
|
|
412
|
+
}
|
|
393
413
|
}
|
|
394
414
|
async #restartBackend(reason: BackendRestartReason) {
|
|
395
415
|
this.logger.verbose(
|
|
@@ -719,6 +739,7 @@ export class AkanAppHost {
|
|
|
719
739
|
return !!(
|
|
720
740
|
this.#pendingRecycle ||
|
|
721
741
|
this.#restartTimer ||
|
|
742
|
+
this.#backendRestart ||
|
|
722
743
|
this.#backendRecoveryTimer ||
|
|
723
744
|
this.#builderRecoveryTimer ||
|
|
724
745
|
this.#rssRecycleReason
|
|
@@ -1053,6 +1074,7 @@ export class AkanAppHost {
|
|
|
1053
1074
|
this.#clearRestartTimers();
|
|
1054
1075
|
this.#builderRecoveryAttempts = 0;
|
|
1055
1076
|
this.#pendingRestartReason = null;
|
|
1077
|
+
await this.#backendRestart;
|
|
1056
1078
|
this.#lastGoodFrontend = {};
|
|
1057
1079
|
this.#buildStatusByPhase.clear();
|
|
1058
1080
|
this.#pendingBuildStatusReplay = [];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AkanBin } from "./akanBin";
|
|
4
|
+
|
|
5
|
+
const owner = "apps/portal/akan.config.ts";
|
|
6
|
+
const sha256 = "A".repeat(64);
|
|
7
|
+
|
|
8
|
+
describe("AkanBin.parse", () => {
|
|
9
|
+
test("takes a download checked by its hash, or a file relative to the declaring folder", () => {
|
|
10
|
+
expect(
|
|
11
|
+
AkanBin.parse(
|
|
12
|
+
{
|
|
13
|
+
ffmpeg: {
|
|
14
|
+
"linux-x64": { url: "https://files.test/ffmpeg-linux64-lgpl.tar.xz", sha256, file: "./ffmpeg\\bin/ffmpeg" },
|
|
15
|
+
"darwin-arm64": { path: "tools/ffmpeg" },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
owner,
|
|
19
|
+
"/repo/apps/portal",
|
|
20
|
+
),
|
|
21
|
+
).toEqual({
|
|
22
|
+
ffmpeg: {
|
|
23
|
+
"linux-x64": {
|
|
24
|
+
url: "https://files.test/ffmpeg-linux64-lgpl.tar.xz",
|
|
25
|
+
sha256: "a".repeat(64),
|
|
26
|
+
file: "ffmpeg/bin/ffmpeg",
|
|
27
|
+
},
|
|
28
|
+
"darwin-arm64": { path: path.resolve("/repo/apps/portal/tools/ffmpeg") },
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
expect(AkanBin.parse(undefined, owner, "/repo/apps/portal")).toEqual({});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("names the declaration that is wrong", () => {
|
|
35
|
+
const parse = (bin: unknown) => () => AkanBin.parse(bin, owner, "/repo/apps/portal");
|
|
36
|
+
expect(parse(["ffmpeg"])).toThrow(`${owner}: bin maps each executable's name`);
|
|
37
|
+
expect(parse({ "../ffmpeg": {} })).toThrow("bin.../ffmpeg is not a file name");
|
|
38
|
+
expect(parse({ ffmpeg: { "darwin-universal": { path: "x" } } })).toThrow(
|
|
39
|
+
"bin.ffmpeg.darwin-universal is not one of darwin-arm64",
|
|
40
|
+
);
|
|
41
|
+
expect(parse({ ffmpeg: { "linux-x64": { url: "https://x.test/ffmpeg", sha256, path: "x" } } })).toThrow(
|
|
42
|
+
"takes either url and sha256, or path",
|
|
43
|
+
);
|
|
44
|
+
expect(parse({ ffmpeg: { "linux-x64": { url: "ftp://x.test/ffmpeg", sha256 } } })).toThrow(
|
|
45
|
+
"url must be an http(s) address",
|
|
46
|
+
);
|
|
47
|
+
expect(parse({ ffmpeg: { "linux-x64": { url: "https://x.test/ffmpeg", sha256: "abc" } } })).toThrow(
|
|
48
|
+
"sha256 must be the downloaded file's 64-digit hex digest",
|
|
49
|
+
);
|
|
50
|
+
expect(parse({ ffmpeg: { "linux-x64": { path: "ffmpeg", sha256 } } })).toThrow("a path source downloads nothing");
|
|
51
|
+
expect(parse({ ffmpeg: { "linux-x64": { url: "https://x.test/ffmpeg.zip", sha256 } } })).toThrow(
|
|
52
|
+
"names an archive: file picks the executable inside it",
|
|
53
|
+
);
|
|
54
|
+
expect(parse({ ffmpeg: { "linux-x64": { path: "ffmpeg", file: "bin/ffmpeg" } } })).toThrow(
|
|
55
|
+
"file picks the executable out of an archive",
|
|
56
|
+
);
|
|
57
|
+
expect(parse({ ffmpeg: { "linux-x64": { path: "ffmpeg.tar.gz", file: "../ffmpeg" } } })).toThrow(
|
|
58
|
+
"file must be a path inside the archive",
|
|
59
|
+
);
|
|
60
|
+
expect(parse({ ffmpeg: { "linux-x64": { path: "ffmpeg", mode: 755 } } })).toThrow("unknown key mode");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
type AkanBinConfig,
|
|
4
|
+
type AkanBinPathSource,
|
|
5
|
+
type AkanBinSource,
|
|
6
|
+
type AkanBinUrlSource,
|
|
7
|
+
type BinPlatform,
|
|
8
|
+
binPlatforms,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
//* Checked when the config loads, and a `path` source is made absolute against the folder that declares it: a lib's
|
|
12
|
+
//* relative path has to find its file from every app that carries it.
|
|
13
|
+
export class AkanBin {
|
|
14
|
+
static readonly archive = /\.(zip|tar\.gz|tgz|tar\.xz|txz|tar\.bz2|tbz2|tar)$/i;
|
|
15
|
+
static readonly fileName = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
16
|
+
|
|
17
|
+
static parse(declared: unknown, owner: string, baseDir: string): AkanBinConfig {
|
|
18
|
+
if (declared === undefined) return {};
|
|
19
|
+
if (!declared || typeof declared !== "object" || Array.isArray(declared))
|
|
20
|
+
throw new Error(`${owner}: bin maps each executable's name to its platforms`);
|
|
21
|
+
return Object.fromEntries(
|
|
22
|
+
Object.entries(declared).map(([name, platforms]) => [name, AkanBin.#platforms(name, platforms, owner, baseDir)]),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static isArchive(source: AkanBinSource): boolean {
|
|
27
|
+
return AkanBin.archive.test("url" in source ? new URL(source.url).pathname : source.path);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static #platforms(name: string, platforms: unknown, owner: string, baseDir: string) {
|
|
31
|
+
if (!AkanBin.fileName.test(name)) throw new Error(`${owner}: bin.${name} is not a file name`);
|
|
32
|
+
if (!platforms || typeof platforms !== "object" || Array.isArray(platforms))
|
|
33
|
+
throw new Error(`${owner}: bin.${name} maps platforms (${binPlatforms.join(", ")}) to where the file comes from`);
|
|
34
|
+
const resolved: { [platform in BinPlatform]?: AkanBinSource } = {};
|
|
35
|
+
for (const [platform, source] of Object.entries(platforms)) {
|
|
36
|
+
if (!(binPlatforms as readonly string[]).includes(platform))
|
|
37
|
+
throw new Error(`${owner}: bin.${name}.${platform} is not one of ${binPlatforms.join(", ")}`);
|
|
38
|
+
resolved[platform as BinPlatform] = AkanBin.#source(`${owner}: bin.${name}.${platform}`, source, baseDir);
|
|
39
|
+
}
|
|
40
|
+
return resolved;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static #source(at: string, source: unknown, baseDir: string): AkanBinSource {
|
|
44
|
+
if (!source || typeof source !== "object" || Array.isArray(source))
|
|
45
|
+
throw new Error(`${at} must be { url, sha256 } or { path }`);
|
|
46
|
+
const { url, sha256, path: localPath, file, ...rest } = source as Record<string, unknown>;
|
|
47
|
+
const extra = Object.keys(rest);
|
|
48
|
+
if (extra.length) throw new Error(`${at}: unknown key ${extra.join(", ")}`);
|
|
49
|
+
if ((url === undefined) === (localPath === undefined))
|
|
50
|
+
throw new Error(`${at} takes either url and sha256, or path`);
|
|
51
|
+
const resolved =
|
|
52
|
+
url !== undefined ? AkanBin.#urlSource(at, url, sha256) : AkanBin.#pathSource(at, localPath, sha256, baseDir);
|
|
53
|
+
const archive = AkanBin.isArchive(resolved);
|
|
54
|
+
if (file === undefined) {
|
|
55
|
+
if (archive) throw new Error(`${at} names an archive: file picks the executable inside it`);
|
|
56
|
+
return resolved;
|
|
57
|
+
}
|
|
58
|
+
if (!archive) throw new Error(`${at}: file picks the executable out of an archive, and this source is none`);
|
|
59
|
+
const parts = typeof file === "string" ? file.split(/[\\/]/).filter((part) => part && part !== ".") : [];
|
|
60
|
+
if (!parts.length || path.isAbsolute(file as string) || parts.includes(".."))
|
|
61
|
+
throw new Error(`${at}: file must be a path inside the archive (got ${JSON.stringify(file)})`);
|
|
62
|
+
return { ...resolved, file: parts.join("/") };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static #urlSource(at: string, url: unknown, sha256: unknown): AkanBinUrlSource {
|
|
66
|
+
if (typeof url !== "string" || !URL.canParse(url) || !/^https?:$/.test(new URL(url).protocol))
|
|
67
|
+
throw new Error(`${at}: url must be an http(s) address`);
|
|
68
|
+
if (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/i.test(sha256))
|
|
69
|
+
throw new Error(`${at}: sha256 must be the downloaded file's 64-digit hex digest`);
|
|
70
|
+
return { url, sha256: sha256.toLowerCase() };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static #pathSource(at: string, localPath: unknown, sha256: unknown, baseDir: string): AkanBinPathSource {
|
|
74
|
+
if (typeof localPath !== "string" || !localPath) throw new Error(`${at}: path must name a file`);
|
|
75
|
+
if (sha256 !== undefined) throw new Error(`${at}: sha256 checks a download, and a path source downloads nothing`);
|
|
76
|
+
return { path: path.resolve(baseDir, localPath) };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { AppExecutor, WorkspaceExecutor } from "../executors";
|
|
7
7
|
import type { PackageJson } from "../types";
|
|
8
8
|
import { AkanAppConfig, AkanLibConfig, deriveDefaultAppId } from "./akanConfig";
|
|
9
|
-
import type {
|
|
9
|
+
import type { LibConfigInput } from "./types";
|
|
10
10
|
|
|
11
11
|
const akanPackageJson = JSON.parse(
|
|
12
12
|
fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../akanjs/package.json"), "utf8"),
|
|
@@ -172,6 +172,75 @@ describe("AkanAppConfig", () => {
|
|
|
172
172
|
expect(new AkanAppConfig(app, [], packageJson, { web: true }, baseDevEnv).web).toEqual({ ssr: true, csr: true });
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
test("writes the image env from getProductionEnv, one ENV line per key in its order", () => {
|
|
176
|
+
const config = new AkanAppConfig(
|
|
177
|
+
app,
|
|
178
|
+
[],
|
|
179
|
+
packageJson,
|
|
180
|
+
{ routes: [{ basePath: "admin", domains: {} }], web: false, database: { modes: ["single", "cluster"] } },
|
|
181
|
+
baseDevEnv,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
expect(config.getProductionEnv()).toEqual({
|
|
185
|
+
PORT: "8282",
|
|
186
|
+
NODE_ENV: "production",
|
|
187
|
+
AKAN_PUBLIC_REPO_NAME: "akanjs",
|
|
188
|
+
AKAN_PUBLIC_SERVE_DOMAIN: "akanjs.com",
|
|
189
|
+
AKAN_PUBLIC_APP_NAME: "portal",
|
|
190
|
+
AKAN_PUBLIC_ENV: "debug",
|
|
191
|
+
AKAN_PUBLIC_BASE_PATHS: "admin",
|
|
192
|
+
AKAN_PUBLIC_DEFAULT_LOCALE: "en",
|
|
193
|
+
AKAN_PUBLIC_LOCALES: config.i18n.locales.join(","),
|
|
194
|
+
AKAN_PUBLIC_API_PREFIX: "/api",
|
|
195
|
+
AKAN_PUBLIC_WS_PREFIX: "/ws",
|
|
196
|
+
AKAN_PUBLIC_OPERATION_MODE: "cloud",
|
|
197
|
+
AKAN_DATABASE_MODES: "single,cluster",
|
|
198
|
+
AKAN_LOG_TO_FILE: "0",
|
|
199
|
+
AKAN_SSR: "false",
|
|
200
|
+
AKAN_CSR: "false",
|
|
201
|
+
});
|
|
202
|
+
const envBlock = Object.entries(config.getProductionEnv())
|
|
203
|
+
.map(([key, value]) => `ENV ${key}=${value}`)
|
|
204
|
+
.join("\n");
|
|
205
|
+
expect(config.dockerfile).toContain(`COPY . .\n${envBlock}\nCMD ["bun","main.js"]`);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("writes the Dockerfile instructions it wrote before its env came from getProductionEnv", () => {
|
|
209
|
+
//? 9adfb95c's output for this config; only the blank lines its empty interpolations left are gone since.
|
|
210
|
+
const before = [
|
|
211
|
+
"FROM oven/bun:1-slim",
|
|
212
|
+
"RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/*",
|
|
213
|
+
"RUN ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime",
|
|
214
|
+
"ARG TARGETARCH",
|
|
215
|
+
"",
|
|
216
|
+
"RUN mkdir -p /workspace",
|
|
217
|
+
"WORKDIR /workspace",
|
|
218
|
+
"COPY ./package.json ./package.json",
|
|
219
|
+
"RUN bun install --production",
|
|
220
|
+
"",
|
|
221
|
+
"COPY . .",
|
|
222
|
+
"ENV PORT=8282",
|
|
223
|
+
"ENV NODE_ENV=production",
|
|
224
|
+
"ENV AKAN_PUBLIC_REPO_NAME=akanjs",
|
|
225
|
+
"ENV AKAN_PUBLIC_SERVE_DOMAIN=akanjs.com",
|
|
226
|
+
"ENV AKAN_PUBLIC_APP_NAME=portal",
|
|
227
|
+
"ENV AKAN_PUBLIC_ENV=debug",
|
|
228
|
+
"",
|
|
229
|
+
"ENV AKAN_PUBLIC_DEFAULT_LOCALE=en",
|
|
230
|
+
"ENV AKAN_PUBLIC_LOCALES=en,ko",
|
|
231
|
+
"ENV AKAN_PUBLIC_API_PREFIX=/api",
|
|
232
|
+
"ENV AKAN_PUBLIC_WS_PREFIX=/ws",
|
|
233
|
+
"ENV AKAN_PUBLIC_OPERATION_MODE=cloud",
|
|
234
|
+
"ENV AKAN_DATABASE_MODES=single",
|
|
235
|
+
"ENV AKAN_LOG_TO_FILE=0",
|
|
236
|
+
"",
|
|
237
|
+
'CMD ["bun","main.js"]',
|
|
238
|
+
];
|
|
239
|
+
const lines = (text: string) => text.split("\n").filter((line) => line !== "");
|
|
240
|
+
|
|
241
|
+
expect(lines(new AkanAppConfig(app, [], packageJson, {}, baseDevEnv).dockerfile)).toEqual(lines(before.join("\n")));
|
|
242
|
+
});
|
|
243
|
+
|
|
175
244
|
test("refuses a csr-less build that ships a mobile app", () => {
|
|
176
245
|
expect(
|
|
177
246
|
() => new AkanAppConfig(app, [], packageJson, { web: { csr: false }, mobile: { appName: "portal" } }, baseDevEnv),
|
|
@@ -458,6 +527,84 @@ describe("AkanAppConfig", () => {
|
|
|
458
527
|
});
|
|
459
528
|
});
|
|
460
529
|
|
|
530
|
+
test("gives every target the mobile-wide updates, a target overriding a field", () => {
|
|
531
|
+
const updates = { url: "https://releases.example.com/portal", publicKey: "key=" };
|
|
532
|
+
const config = new AkanAppConfig(
|
|
533
|
+
app,
|
|
534
|
+
[],
|
|
535
|
+
packageJson,
|
|
536
|
+
{
|
|
537
|
+
mobile: {
|
|
538
|
+
updates,
|
|
539
|
+
targets: { default: {}, pilot: { updates: { channel: "pilot" } } },
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
baseDevEnv,
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
expect(config.mobile.targets.default?.updates).toEqual(updates);
|
|
546
|
+
expect(config.mobile.targets.pilot?.updates).toEqual({ ...updates, channel: "pilot" });
|
|
547
|
+
expect(
|
|
548
|
+
() =>
|
|
549
|
+
new AkanAppConfig(
|
|
550
|
+
app,
|
|
551
|
+
[],
|
|
552
|
+
packageJson,
|
|
553
|
+
{ mobile: { targets: { pilot: { updates: { channel: "pilot" } } } } },
|
|
554
|
+
baseDevEnv,
|
|
555
|
+
),
|
|
556
|
+
).toThrow("mobile.targets.pilot.updates in apps/portal/akan.config.ts has no url or publicKey");
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("merges the desktop settings field by field, the target winning", () => {
|
|
560
|
+
const config = new AkanAppConfig(
|
|
561
|
+
app,
|
|
562
|
+
[],
|
|
563
|
+
packageJson,
|
|
564
|
+
{
|
|
565
|
+
mobile: {
|
|
566
|
+
native: { desktop: { recovery: "reload", window: { fullscreen: true } } },
|
|
567
|
+
targets: { default: { native: { desktop: { window: { skipTaskbar: true } } } } },
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
baseDevEnv,
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
expect(config.mobile.targets.default?.native?.desktop).toEqual({
|
|
574
|
+
recovery: "reload",
|
|
575
|
+
window: { fullscreen: true, skipTaskbar: true },
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("merges the mobile-wide updates, push and privacy into each target field by field, the target winning", () => {
|
|
580
|
+
const publicKey = Buffer.alloc(32, 7).toString("base64");
|
|
581
|
+
const config = new AkanAppConfig(
|
|
582
|
+
app,
|
|
583
|
+
[],
|
|
584
|
+
packageJson,
|
|
585
|
+
{
|
|
586
|
+
mobile: {
|
|
587
|
+
updates: { url: "https://updates.example.com/portal", publicKey },
|
|
588
|
+
native: { push: { android: { color: "#ff5a5f" } }, privacy: { tracking: false } },
|
|
589
|
+
targets: {
|
|
590
|
+
default: {},
|
|
591
|
+
beta: {
|
|
592
|
+
updates: { channel: "beta" },
|
|
593
|
+
native: { push: { android: { smallIcon: "assets/noti.png" } } },
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
} as never,
|
|
598
|
+
baseDevEnv,
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
expect(config.mobile.targets.default?.updates).toEqual({ url: "https://updates.example.com/portal", publicKey });
|
|
602
|
+
expect(config.mobile.targets.beta).toMatchObject({
|
|
603
|
+
updates: { url: "https://updates.example.com/portal", publicKey, channel: "beta" },
|
|
604
|
+
native: { push: { android: { color: "#ff5a5f", smallIcon: "assets/noti.png" } }, privacy: { tracking: false } },
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
|
|
461
608
|
test("refuses the Capacitor-era keys with the setting that replaces them", () => {
|
|
462
609
|
const make = (mobile: Record<string, unknown>) => () =>
|
|
463
610
|
new AkanAppConfig(app, [], packageJson, { mobile } as never, baseDevEnv);
|
|
@@ -587,12 +734,75 @@ describe("AkanAppConfig lib docker runs", () => {
|
|
|
587
734
|
});
|
|
588
735
|
});
|
|
589
736
|
|
|
737
|
+
describe("AkanAppConfig trustedDependencies and bin", () => {
|
|
738
|
+
const sha256 = "b".repeat(64);
|
|
739
|
+
const libBin = { ffmpeg: { "linux-x64": { url: "https://files.test/ffmpeg.tar.xz", sha256, file: "bin/ffmpeg" } } };
|
|
740
|
+
|
|
741
|
+
test("the production package.json trusts the app's and its libs' packages, and nothing when none are named", () => {
|
|
742
|
+
const withTrusted = new AkanAppConfig(
|
|
743
|
+
app,
|
|
744
|
+
[],
|
|
745
|
+
packageJson,
|
|
746
|
+
{ trustedDependencies: [" rclnodejs ", "sharp"] },
|
|
747
|
+
baseDevEnv,
|
|
748
|
+
[],
|
|
749
|
+
{
|
|
750
|
+
externalLibs: [],
|
|
751
|
+
trustedDependencies: ["sharp", "@serialport/bindings-cpp"],
|
|
752
|
+
docker: { preRuns: [], postRuns: [] },
|
|
753
|
+
},
|
|
754
|
+
);
|
|
755
|
+
expect(withTrusted.getProductionPackageJson().trustedDependencies).toEqual([
|
|
756
|
+
"rclnodejs",
|
|
757
|
+
"sharp",
|
|
758
|
+
"@serialport/bindings-cpp",
|
|
759
|
+
]);
|
|
760
|
+
expect(new AkanAppConfig(app, [], packageJson, {}, baseDevEnv).getProductionPackageJson()).not.toHaveProperty(
|
|
761
|
+
"trustedDependencies",
|
|
762
|
+
);
|
|
763
|
+
expect(() => new AkanAppConfig(app, [], packageJson, { trustedDependencies: [""] }, baseDevEnv)).toThrow(
|
|
764
|
+
"apps/portal/akan.config.ts: trustedDependencies lists package names",
|
|
765
|
+
);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
test("keeps the app's bin apart from each lib's, with paths made absolute where they were declared", () => {
|
|
769
|
+
const config = new AkanAppConfig(
|
|
770
|
+
{ name: "portal", cwdPath: "/repo/apps/portal" } as never,
|
|
771
|
+
[],
|
|
772
|
+
packageJson,
|
|
773
|
+
{ bin: { ffmpeg: { "darwin-arm64": { path: "tools/ffmpeg" } } } },
|
|
774
|
+
baseDevEnv,
|
|
775
|
+
[],
|
|
776
|
+
{ externalLibs: [], docker: { preRuns: [], postRuns: [] }, bin: [{ lib: "media", bin: libBin }] },
|
|
777
|
+
);
|
|
778
|
+
expect(config.bin).toEqual({
|
|
779
|
+
ffmpeg: { "darwin-arm64": { path: path.resolve("/repo/apps/portal/tools/ffmpeg") } },
|
|
780
|
+
});
|
|
781
|
+
expect(config.libBins).toEqual([{ lib: "media", bin: libBin }]);
|
|
782
|
+
expect(
|
|
783
|
+
new AkanLibConfig({ name: "media", cwdPath: "/repo/libs/media" } as never, {
|
|
784
|
+
bin: { ffprobe: { "linux-x64": { path: "../../tools/ffprobe" } } },
|
|
785
|
+
}).bin,
|
|
786
|
+
).toEqual({ ffprobe: { "linux-x64": { path: path.resolve("/repo/tools/ffprobe") } } });
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
test("reads them off every workspace lib config on load", async () => {
|
|
790
|
+
const config = await loadExtAppConfig(
|
|
791
|
+
"akan-config-libbin-",
|
|
792
|
+
"export default { trustedDependencies: ['sharp'] };\n",
|
|
793
|
+
`export default { trustedDependencies: ['rclnodejs'], bin: ${JSON.stringify(libBin)} };\n`,
|
|
794
|
+
);
|
|
795
|
+
expect(config.trustedDependencies).toEqual(["sharp", "rclnodejs"]);
|
|
796
|
+
expect(config.libBins).toEqual([{ lib: "extlib", bin: libBin }]);
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
|
|
590
800
|
describe("AkanLibConfig", () => {
|
|
591
801
|
test("uses empty external libs by default and preserves explicit libs", () => {
|
|
592
802
|
const lib = { name: "shared" } as never;
|
|
593
803
|
expect(new AkanLibConfig(lib, {}).externalLibs).toEqual([]);
|
|
594
804
|
|
|
595
|
-
const config:
|
|
805
|
+
const config: LibConfigInput = {
|
|
596
806
|
externalLibs: ["firebase-admin"],
|
|
597
807
|
};
|
|
598
808
|
expect(new AkanLibConfig(lib, config).externalLibs).toEqual(["firebase-admin"]);
|