@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
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -8,9 +8,11 @@ import type { AkanImageConfig } from "akanjs/server";
|
|
|
8
8
|
import type { App, Lib } from "../commandDecorators";
|
|
9
9
|
import { LibExecutor, WorkspaceExecutor } from "../executors";
|
|
10
10
|
import type { BaseDevEnv, PackageJson } from "../types";
|
|
11
|
+
import { AkanBin } from "./akanBin";
|
|
11
12
|
import {
|
|
12
13
|
type AkanApiConfig,
|
|
13
14
|
type AkanAssetsConfig,
|
|
15
|
+
type AkanBinConfig,
|
|
14
16
|
type AkanDatabaseConfig,
|
|
15
17
|
type AkanMobileConfig,
|
|
16
18
|
type AkanMobileTargetConfig,
|
|
@@ -107,13 +109,19 @@ const normalizeDeepLinks = (deepLinks: DeepPartial<AkanMobileTargetConfig["deepL
|
|
|
107
109
|
} satisfies AkanMobileTargetConfig["deepLinks"];
|
|
108
110
|
};
|
|
109
111
|
|
|
110
|
-
type AppConfigDeclaration = Omit<DeepPartial<AppConfigResult>, "docker" | "web"> & {
|
|
112
|
+
type AppConfigDeclaration = Omit<DeepPartial<AppConfigResult>, "docker" | "web" | "bin"> & {
|
|
111
113
|
docker?: DockerOption;
|
|
112
114
|
web?: AkanWebOption;
|
|
115
|
+
bin?: AkanBinConfig;
|
|
113
116
|
};
|
|
114
117
|
|
|
118
|
+
type LibConfigDeclaration = Omit<DeepPartial<LibConfigResult>, "bin"> & { bin?: AkanBinConfig };
|
|
119
|
+
|
|
115
120
|
export interface LibContributions {
|
|
116
121
|
externalLibs: string[];
|
|
122
|
+
trustedDependencies?: string[];
|
|
123
|
+
/** Per lib, since an app carries only the entries of the libs it depends on. */
|
|
124
|
+
bin?: { lib: string; bin: AkanBinConfig }[];
|
|
117
125
|
docker: LibDockerConfig;
|
|
118
126
|
/** Keep globs rewritten to the app's own `public/`, where `akan sync` mounts each lib's assets. */
|
|
119
127
|
keepFonts?: string[];
|
|
@@ -121,10 +129,19 @@ export interface LibContributions {
|
|
|
121
129
|
|
|
122
130
|
const emptyLibContributions = (): LibContributions => ({
|
|
123
131
|
externalLibs: [],
|
|
132
|
+
trustedDependencies: [],
|
|
133
|
+
bin: [],
|
|
124
134
|
docker: { preRuns: [], postRuns: [] },
|
|
125
135
|
keepFonts: [],
|
|
126
136
|
});
|
|
127
137
|
|
|
138
|
+
const normalizePackageNames = (names: unknown, owner: string): string[] => {
|
|
139
|
+
if (names === undefined) return [];
|
|
140
|
+
if (!Array.isArray(names) || names.some((name) => typeof name !== "string" || !name.trim()))
|
|
141
|
+
throw new Error(`${owner}: trustedDependencies lists package names`);
|
|
142
|
+
return [...new Set(names.map((name: string) => name.trim()))];
|
|
143
|
+
};
|
|
144
|
+
|
|
128
145
|
const normalizeKeepFonts = (keepFonts: string[] | undefined) => [
|
|
129
146
|
...new Set((keepFonts ?? []).map((glob) => glob.trim().replace(/^\/+/, "")).filter(Boolean)),
|
|
130
147
|
];
|
|
@@ -145,6 +162,10 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
145
162
|
database: AkanDatabaseConfig;
|
|
146
163
|
web: AkanWebConfig;
|
|
147
164
|
externalLibs: string[];
|
|
165
|
+
trustedDependencies: string[];
|
|
166
|
+
/** The app's own entries; the libs' are in `libBins`. */
|
|
167
|
+
bin: AkanBinConfig;
|
|
168
|
+
libBins: { lib: string; bin: AkanBinConfig }[];
|
|
148
169
|
barrelImports: string[];
|
|
149
170
|
optimizeImports: string[];
|
|
150
171
|
images: AkanImageConfig;
|
|
@@ -183,6 +204,15 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
183
204
|
this.#applyRoutes(config?.routes);
|
|
184
205
|
this.database = AkanAppConfig.#database(app, config);
|
|
185
206
|
this.externalLibs = [...new Set([...(config?.externalLibs ?? []), ...libContributions.externalLibs])];
|
|
207
|
+
const owner = `apps/${app.name}/akan.config.ts`;
|
|
208
|
+
this.trustedDependencies = [
|
|
209
|
+
...new Set([
|
|
210
|
+
...normalizePackageNames(config?.trustedDependencies, owner),
|
|
211
|
+
...(libContributions.trustedDependencies ?? []),
|
|
212
|
+
]),
|
|
213
|
+
];
|
|
214
|
+
this.bin = AkanBin.parse(config?.bin, owner, app.cwdPath);
|
|
215
|
+
this.libBins = libContributions.bin ?? [];
|
|
186
216
|
this.barrelImports = [
|
|
187
217
|
...DEFAULT_BARREL_IMPORTS,
|
|
188
218
|
...WORKSPACE_BARREL_FACETS.map((facet) => `@apps/${app.name}/${facet}`),
|
|
@@ -239,6 +269,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
239
269
|
const buildNum = rawMobile.buildNum ?? 1;
|
|
240
270
|
const files = AkanAppConfig.#resolveMobileFiles(rawMobile.files as AkanMobileConfig["files"], "mobile", configPath);
|
|
241
271
|
const native = rawMobile.native as AkanMobileConfig["native"];
|
|
272
|
+
const updates = rawMobile.updates as AkanMobileConfig["updates"];
|
|
242
273
|
const defaultTargetName = this.#defaultMobileTargetName(rawTargets);
|
|
243
274
|
const targetEntries = Object.entries(
|
|
244
275
|
rawTargets ?? {
|
|
@@ -263,6 +294,11 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
263
294
|
configPath,
|
|
264
295
|
);
|
|
265
296
|
const fileName = (target.fileName ?? rawMobile.fileName) as string | undefined;
|
|
297
|
+
const targetUpdates = updates || target.updates ? { ...updates, ...target.updates } : undefined;
|
|
298
|
+
if (targetUpdates && (!targetUpdates.url || !targetUpdates.publicKey))
|
|
299
|
+
throw new Error(
|
|
300
|
+
`${where}.updates in ${configPath} has no url or publicKey; give them in mobile.updates or the target's own.`,
|
|
301
|
+
);
|
|
266
302
|
const resolved = {
|
|
267
303
|
name,
|
|
268
304
|
basePath,
|
|
@@ -279,6 +315,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
279
315
|
...(native || target.native
|
|
280
316
|
? { native: AkanAppConfig.#mergeNative(native, target.native as AkanMobileTargetConfig["native"]) }
|
|
281
317
|
: {}),
|
|
318
|
+
...(targetUpdates ? { updates: targetUpdates } : {}),
|
|
282
319
|
} satisfies AkanMobileTargetConfig;
|
|
283
320
|
return [name, resolved];
|
|
284
321
|
}),
|
|
@@ -291,10 +328,11 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
291
328
|
buildNum,
|
|
292
329
|
...(files ? { files } : {}),
|
|
293
330
|
...(native ? { native } : {}),
|
|
331
|
+
...(updates ? { updates } : {}),
|
|
294
332
|
targets,
|
|
295
333
|
};
|
|
296
334
|
}
|
|
297
|
-
static readonly #mobileKeys = ["appName", "appId", "fileName", "version", "buildNum", "files", "native"];
|
|
335
|
+
static readonly #mobileKeys = ["appName", "appId", "fileName", "version", "buildNum", "files", "native", "updates"];
|
|
298
336
|
static readonly #targetKeys = [
|
|
299
337
|
"basePath",
|
|
300
338
|
"indexPath",
|
|
@@ -308,6 +346,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
308
346
|
"deepLinks",
|
|
309
347
|
"files",
|
|
310
348
|
"native",
|
|
349
|
+
"updates",
|
|
311
350
|
];
|
|
312
351
|
//* The Capacitor keys used to pass straight through; the native runtime takes typed ones, so a leftover is refused.
|
|
313
352
|
static #assertMobileKeys(section: object, known: string[], where: string, configPath: string) {
|
|
@@ -350,10 +389,21 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
350
389
|
if (merged.length) android[key] = merged;
|
|
351
390
|
else delete android[key];
|
|
352
391
|
}
|
|
392
|
+
const pushAndroid = { ...base?.push?.android, ...override?.push?.android };
|
|
393
|
+
const privacy = { ...base?.privacy, ...override?.privacy };
|
|
394
|
+
const window = { ...base?.desktop?.window, ...override?.desktop?.window };
|
|
395
|
+
const desktop = {
|
|
396
|
+
...base?.desktop,
|
|
397
|
+
...override?.desktop,
|
|
398
|
+
...(Object.keys(window).length ? { window } : {}),
|
|
399
|
+
};
|
|
353
400
|
return {
|
|
354
401
|
...(plugins.length ? { plugins } : {}),
|
|
355
402
|
...(Object.keys(ios).length ? { ios } : {}),
|
|
356
403
|
...(Object.keys(android).length ? { android } : {}),
|
|
404
|
+
...(Object.keys(pushAndroid).length ? { push: { android: pushAndroid } } : {}),
|
|
405
|
+
...(Object.keys(privacy).length ? { privacy } : {}),
|
|
406
|
+
...(Object.keys(desktop).length ? { desktop } : {}),
|
|
357
407
|
};
|
|
358
408
|
}
|
|
359
409
|
#defaultMobileTargetName(rawTargets: DeepPartial<AkanMobileConfig>["targets"] | undefined) {
|
|
@@ -405,18 +455,39 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
405
455
|
command: docker?.command ?? ["bun", "main.js"],
|
|
406
456
|
};
|
|
407
457
|
}
|
|
408
|
-
|
|
458
|
+
/** What the built server runs with in its image; a desktop app's carried server starts from the same values. */
|
|
459
|
+
getProductionEnv(environment: string = this.baseDevEnv.env): Record<string, string> {
|
|
460
|
+
return {
|
|
461
|
+
PORT: "8282",
|
|
462
|
+
NODE_ENV: "production",
|
|
463
|
+
AKAN_PUBLIC_REPO_NAME: this.baseDevEnv.repoName,
|
|
464
|
+
AKAN_PUBLIC_SERVE_DOMAIN: this.baseDevEnv.serveDomain,
|
|
465
|
+
AKAN_PUBLIC_APP_NAME: this.app.name,
|
|
466
|
+
AKAN_PUBLIC_ENV: environment,
|
|
467
|
+
...(this.basePaths.size ? { AKAN_PUBLIC_BASE_PATHS: [...this.basePaths].join(",") } : {}),
|
|
468
|
+
AKAN_PUBLIC_DEFAULT_LOCALE: this.i18n.defaultLocale,
|
|
469
|
+
AKAN_PUBLIC_LOCALES: this.i18n.locales.join(","),
|
|
470
|
+
AKAN_PUBLIC_API_PREFIX: this.api.prefix,
|
|
471
|
+
AKAN_PUBLIC_WS_PREFIX: this.api.websocketPrefix,
|
|
472
|
+
AKAN_PUBLIC_OPERATION_MODE: "cloud",
|
|
473
|
+
AKAN_DATABASE_MODES: this.database.modes.join(","),
|
|
474
|
+
// File logging is off: a container's writable layer is ephemeral and stdout is the collection path.
|
|
475
|
+
AKAN_LOG_TO_FILE: "0",
|
|
476
|
+
// The web env matches what was built (a deployment can only narrow it).
|
|
477
|
+
...(this.web.ssr ? {} : { AKAN_SSR: "false" }),
|
|
478
|
+
...(this.web.csr ? {} : { AKAN_CSR: "false" }),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
dockerfileFor(environment: string): string {
|
|
482
|
+
return this.#makeDockerfile(environment);
|
|
483
|
+
}
|
|
484
|
+
#makeDockerfile(environment?: string): string {
|
|
409
485
|
if (typeof this.docker === "string") return this.docker;
|
|
410
486
|
const { image, preRuns, postRuns, command } = this.docker;
|
|
411
487
|
const preRunScripts = this.#getDockerRunScripts(preRuns);
|
|
412
488
|
const postRunScripts = this.#getDockerRunScripts(postRuns);
|
|
413
489
|
const imageScript = this.#getDockerImageScript(image, DEFAULT_DOCKER_IMAGE);
|
|
414
|
-
|
|
415
|
-
// writable layer is ephemeral and stdout is the collection path.
|
|
416
|
-
const webEnvLines = [
|
|
417
|
-
...(this.web.ssr ? [] : ["ENV AKAN_SSR=false"]),
|
|
418
|
-
...(this.web.csr ? [] : ["ENV AKAN_CSR=false"]),
|
|
419
|
-
].join("\n");
|
|
490
|
+
const envLines = Object.entries(this.getProductionEnv(environment)).map(([key, value]) => `ENV ${key}=${value}`);
|
|
420
491
|
return `${imageScript}
|
|
421
492
|
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/*
|
|
422
493
|
RUN ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime
|
|
@@ -428,21 +499,7 @@ COPY ./package.json ./package.json
|
|
|
428
499
|
RUN bun install --production
|
|
429
500
|
${postRunScripts.join("\n")}
|
|
430
501
|
COPY . .
|
|
431
|
-
|
|
432
|
-
ENV NODE_ENV=production
|
|
433
|
-
ENV AKAN_PUBLIC_REPO_NAME=${this.baseDevEnv.repoName}
|
|
434
|
-
ENV AKAN_PUBLIC_SERVE_DOMAIN=${this.baseDevEnv.serveDomain}
|
|
435
|
-
ENV AKAN_PUBLIC_APP_NAME=${this.app.name}
|
|
436
|
-
ENV AKAN_PUBLIC_ENV=${this.baseDevEnv.env}
|
|
437
|
-
${this.basePaths.size ? `ENV AKAN_PUBLIC_BASE_PATHS=${[...this.basePaths].join(",")}` : ""}
|
|
438
|
-
ENV AKAN_PUBLIC_DEFAULT_LOCALE=${this.i18n.defaultLocale}
|
|
439
|
-
ENV AKAN_PUBLIC_LOCALES=${this.i18n.locales.join(",")}
|
|
440
|
-
ENV AKAN_PUBLIC_API_PREFIX=${this.api.prefix}
|
|
441
|
-
ENV AKAN_PUBLIC_WS_PREFIX=${this.api.websocketPrefix}
|
|
442
|
-
ENV AKAN_PUBLIC_OPERATION_MODE=cloud
|
|
443
|
-
ENV AKAN_DATABASE_MODES=${this.database.modes.join(",")}
|
|
444
|
-
ENV AKAN_LOG_TO_FILE=0
|
|
445
|
-
${webEnvLines}
|
|
502
|
+
${envLines.join("\n")}
|
|
446
503
|
CMD [${command.map((c) => `"${c}"`).join(",")}]`;
|
|
447
504
|
}
|
|
448
505
|
static #importGeneration = 0;
|
|
@@ -485,6 +542,10 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
|
|
|
485
542
|
);
|
|
486
543
|
return {
|
|
487
544
|
externalLibs: libConfigs.flatMap((libConfig) => libConfig?.externalLibs ?? []),
|
|
545
|
+
trustedDependencies: libConfigs.flatMap((libConfig) => libConfig?.trustedDependencies ?? []),
|
|
546
|
+
bin: libConfigs.flatMap((libConfig) =>
|
|
547
|
+
libConfig && Object.keys(libConfig.bin).length ? [{ lib: libConfig.lib.name, bin: libConfig.bin }] : [],
|
|
548
|
+
),
|
|
488
549
|
docker: {
|
|
489
550
|
preRuns: libConfigs.flatMap((libConfig) => libConfig?.docker.preRuns ?? []),
|
|
490
551
|
postRuns: libConfigs.flatMap((libConfig) => libConfig?.docker.postRuns ?? []),
|
|
@@ -554,6 +615,7 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
|
|
|
554
615
|
this.#resolveProductionDependencyVersion(lib),
|
|
555
616
|
]),
|
|
556
617
|
),
|
|
618
|
+
...(this.trustedDependencies.length ? { trustedDependencies: this.trustedDependencies } : {}),
|
|
557
619
|
...data,
|
|
558
620
|
};
|
|
559
621
|
}
|
|
@@ -607,13 +669,18 @@ function mergeImageConfig(config: Partial<AkanImageConfig> = {}): AkanImageConfi
|
|
|
607
669
|
export class AkanLibConfig implements LibConfigResult {
|
|
608
670
|
lib: Lib;
|
|
609
671
|
externalLibs: string[];
|
|
672
|
+
trustedDependencies: string[];
|
|
673
|
+
bin: AkanBinConfig;
|
|
610
674
|
docker: LibDockerConfig;
|
|
611
675
|
assets: LibAssetsConfig;
|
|
612
676
|
/** Live-only: plugins declared in this lib's `akan.config.ts` (never serialized). */
|
|
613
677
|
plugins: AkanPlugin[];
|
|
614
|
-
constructor(lib: Lib, config:
|
|
678
|
+
constructor(lib: Lib, config: LibConfigDeclaration, plugins: AkanPlugin[] = []) {
|
|
615
679
|
this.lib = lib;
|
|
616
680
|
this.externalLibs = config?.externalLibs ?? [];
|
|
681
|
+
const owner = `libs/${lib.name}/akan.config.ts`;
|
|
682
|
+
this.trustedDependencies = normalizePackageNames(config?.trustedDependencies, owner);
|
|
683
|
+
this.bin = AkanBin.parse(config?.bin, owner, lib.cwdPath);
|
|
617
684
|
this.docker = { preRuns: config?.docker?.preRuns ?? [], postRuns: config?.docker?.postRuns ?? [] };
|
|
618
685
|
this.assets = { keepFonts: normalizeKeepFonts(config?.assets?.keepFonts as string[] | undefined) };
|
|
619
686
|
this.plugins = plugins;
|
|
@@ -621,7 +688,7 @@ export class AkanLibConfig implements LibConfigResult {
|
|
|
621
688
|
static async from(lib: Lib, { bustImportCache = false }: { bustImportCache?: boolean } = {}) {
|
|
622
689
|
const configImp = await AkanAppConfig.importConfigModule(lib.cwdPath, { bustImportCache });
|
|
623
690
|
const resolved = typeof configImp === "function" ? configImp(lib) : configImp;
|
|
624
|
-
const { plugins, ...config } = (resolved ?? {}) as
|
|
691
|
+
const { plugins, ...config } = (resolved ?? {}) as LibConfigDeclaration & { plugins?: AkanPlugin[] };
|
|
625
692
|
return new AkanLibConfig(lib, config, plugins ?? []);
|
|
626
693
|
}
|
|
627
694
|
}
|
package/akanConfig/index.ts
CHANGED
package/akanConfig/types.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
export type {
|
|
2
2
|
AkanApiConfig,
|
|
3
3
|
AkanAssetsConfig,
|
|
4
|
+
AkanBinConfig,
|
|
5
|
+
AkanBinPathSource,
|
|
6
|
+
AkanBinSource,
|
|
7
|
+
AkanBinUrlSource,
|
|
4
8
|
AkanConfigFile,
|
|
5
9
|
AkanDatabaseConfig,
|
|
6
10
|
AkanExecutor,
|
|
7
11
|
AkanMobileConfig,
|
|
8
12
|
AkanMobileNativeConfig,
|
|
9
13
|
AkanMobileTargetConfig,
|
|
14
|
+
AkanMobileUpdatesConfig,
|
|
10
15
|
AkanNativeValue,
|
|
11
16
|
AkanPlugin,
|
|
12
17
|
AkanPluginNativeConfig,
|
|
@@ -20,6 +25,7 @@ export type {
|
|
|
20
25
|
AppConfigResult,
|
|
21
26
|
AppScanResult,
|
|
22
27
|
Arch,
|
|
28
|
+
BinPlatform,
|
|
23
29
|
DatabaseMode,
|
|
24
30
|
DeepPartial,
|
|
25
31
|
DockerConfig,
|
|
@@ -39,4 +45,4 @@ export type {
|
|
|
39
45
|
ScanResult,
|
|
40
46
|
WorkspaceScanResult,
|
|
41
47
|
} from "akanjs";
|
|
42
|
-
export { archs } from "akanjs";
|
|
48
|
+
export { archs, binPlatforms } from "akanjs";
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { rm } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { AkanAppConfig } from "./akanConfig";
|
|
6
|
+
import { AKAN_BACKEND_MINIFY, AKAN_OPTIONAL_BACKEND_EXTERNALS, ApplicationBuildRunner } from "./applicationBuildRunner";
|
|
7
|
+
import type { App } from "./commandDecorators";
|
|
8
|
+
import { Executor } from "./executors";
|
|
6
9
|
|
|
7
10
|
describe("ApplicationBuildRunner", () => {
|
|
8
11
|
test("externalizes Akan optional backend dependencies", () => {
|
|
@@ -44,4 +47,35 @@ console.info(new SampleService().name);
|
|
|
44
47
|
await rm(dir, { recursive: true, force: true });
|
|
45
48
|
}
|
|
46
49
|
});
|
|
50
|
+
|
|
51
|
+
test("an image of a --env build boots the env its server carries, not the root .env's", async () => {
|
|
52
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-build-meta-"));
|
|
53
|
+
const baseDevEnv = {
|
|
54
|
+
repoName: "akanjs",
|
|
55
|
+
serveDomain: "akanjs.com",
|
|
56
|
+
env: "local" as const,
|
|
57
|
+
portOffset: 0,
|
|
58
|
+
workspaceRoot: root,
|
|
59
|
+
};
|
|
60
|
+
const config = new AkanAppConfig(
|
|
61
|
+
{ name: "portal" } as never,
|
|
62
|
+
[],
|
|
63
|
+
{ name: "repo", version: "1.0.0", description: "repo" },
|
|
64
|
+
{},
|
|
65
|
+
baseDevEnv,
|
|
66
|
+
);
|
|
67
|
+
const app = {
|
|
68
|
+
getConfig: async () => config,
|
|
69
|
+
dist: new Executor("dist", root),
|
|
70
|
+
workspace: { workspaceRoot: root },
|
|
71
|
+
} as unknown as App;
|
|
72
|
+
try {
|
|
73
|
+
await new ApplicationBuildRunner(app, { environment: "debug" }).buildAppMeta();
|
|
74
|
+
expect(await readFile(path.join(root, "Dockerfile"), "utf8")).toContain("ENV AKAN_PUBLIC_ENV=debug\n");
|
|
75
|
+
await new ApplicationBuildRunner(app).buildAppMeta();
|
|
76
|
+
expect(await readFile(path.join(root, "Dockerfile"), "utf8")).toContain("ENV AKAN_PUBLIC_ENV=local\n");
|
|
77
|
+
} finally {
|
|
78
|
+
await rm(root, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
47
81
|
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
SsrBaseArtifactBuilder,
|
|
16
16
|
} from "./frontendBuild";
|
|
17
17
|
import { Spinner } from "./spinner";
|
|
18
|
+
import { createServerEnvPlugin } from "./transforms/serverEnvPlugin";
|
|
18
19
|
|
|
19
20
|
export interface TypecheckOptions {
|
|
20
21
|
clean?: boolean;
|
|
@@ -29,6 +30,8 @@ export type BuildProgressReporter = ApplicationBuildProgressReporter;
|
|
|
29
30
|
export interface ApplicationBuildRunnerOptions {
|
|
30
31
|
fast?: boolean;
|
|
31
32
|
reporter?: BuildProgressReporter;
|
|
33
|
+
/** The backend env the build is for, when it is not the workspace's own (a mobile or desktop build's `--env`). */
|
|
34
|
+
environment?: string;
|
|
32
35
|
}
|
|
33
36
|
export interface BuildOptions {
|
|
34
37
|
spinner?: boolean;
|
|
@@ -76,14 +79,16 @@ export class ApplicationBuildRunner {
|
|
|
76
79
|
#app: App;
|
|
77
80
|
#fast: boolean;
|
|
78
81
|
#reporter?: BuildProgressReporter;
|
|
82
|
+
#environment?: string;
|
|
79
83
|
#spinner?: boolean;
|
|
80
84
|
#startedAt = Date.now();
|
|
81
85
|
#phases: BuildPhaseResult[] = [];
|
|
82
86
|
|
|
83
|
-
constructor(app: App, { fast = false, reporter }: ApplicationBuildRunnerOptions = {}) {
|
|
87
|
+
constructor(app: App, { fast = false, reporter, environment }: ApplicationBuildRunnerOptions = {}) {
|
|
84
88
|
this.#app = app;
|
|
85
89
|
this.#fast = fast;
|
|
86
90
|
this.#reporter = reporter;
|
|
91
|
+
this.#environment = environment;
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
async build({ spinner = false }: BuildOptions = {}): Promise<BuildResult> {
|
|
@@ -133,7 +138,7 @@ export class ApplicationBuildRunner {
|
|
|
133
138
|
? `${result.files} files, ${ApplicationBuildRunner.formatBytes(result.inputBytes)} -> gzip ${ApplicationBuildRunner.formatBytes(result.outputBytes)} / br ${ApplicationBuildRunner.formatBytes(result.brotliBytes)}`
|
|
134
139
|
: "no files",
|
|
135
140
|
);
|
|
136
|
-
await this.#runPhase("metadata", "Writing production metadata", () => this
|
|
141
|
+
await this.#runPhase("metadata", "Writing production metadata", () => this.buildAppMeta());
|
|
137
142
|
return {
|
|
138
143
|
phases: this.#phases,
|
|
139
144
|
durationMs: Date.now() - this.#startedAt,
|
|
@@ -175,11 +180,12 @@ export class ApplicationBuildRunner {
|
|
|
175
180
|
}
|
|
176
181
|
}
|
|
177
182
|
|
|
178
|
-
async
|
|
183
|
+
async buildAppMeta() {
|
|
179
184
|
const akanConfig = await this.#app.getConfig();
|
|
185
|
+
const dockerfile = akanConfig.dockerfileFor(this.#environment ?? akanConfig.baseDevEnv.env);
|
|
180
186
|
await Promise.all([
|
|
181
187
|
this.#app.dist.writeJson("package.json", akanConfig.getProductionPackageJson()),
|
|
182
|
-
this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`,
|
|
188
|
+
this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`, dockerfile),
|
|
183
189
|
this.#app.dist.writeJson("akan.build.json", {
|
|
184
190
|
buildId: await this.#resolveBuildId(),
|
|
185
191
|
akanVersion: akanConfig.akanVersion,
|
|
@@ -204,7 +210,7 @@ export class ApplicationBuildRunner {
|
|
|
204
210
|
}
|
|
205
211
|
|
|
206
212
|
async #buildBackend() {
|
|
207
|
-
const { externalLibs, web } = await this.#app.getConfig();
|
|
213
|
+
const { externalLibs, web, baseDevEnv, branches } = await this.#app.getConfig();
|
|
208
214
|
const backendEntryPoints = [`${this.#app.cwdPath}/main.ts`, `${this.#app.cwdPath}/server.ts`];
|
|
209
215
|
for (const entrypoint of backendEntryPoints) {
|
|
210
216
|
if (!(await Bun.file(entrypoint).exists())) throw new Error(`Backend entrypoint not found: ${entrypoint}`);
|
|
@@ -217,7 +223,16 @@ export class ApplicationBuildRunner {
|
|
|
217
223
|
// `akan build` must embed production react-server-dom regardless of the shell's NODE_ENV.
|
|
218
224
|
define: { "process.env.NODE_ENV": JSON.stringify("production") },
|
|
219
225
|
} satisfies Omit<Bun.BuildConfig, "entrypoints">;
|
|
220
|
-
const
|
|
226
|
+
const serverEnvPlugin = createServerEnvPlugin({
|
|
227
|
+
envDir: path.join(this.#app.cwdPath, "env"),
|
|
228
|
+
//? The root .env wins over the shell in baseDevEnv, so a `--env` build names its own; a desktop app runs it.
|
|
229
|
+
environment: this.#environment ?? baseDevEnv.env,
|
|
230
|
+
environments: ["local", "testing", ...branches],
|
|
231
|
+
});
|
|
232
|
+
const backendConfig = {
|
|
233
|
+
...sharedConfig,
|
|
234
|
+
plugins: [this.#createExternalSpecifiersPlugin(externalLibs), serverEnvPlugin],
|
|
235
|
+
};
|
|
221
236
|
//* Built apart so main.js keeps its own module copies; splitting moves lazy vendor `import()`s out of the boot parse.
|
|
222
237
|
const [mainResult, serverResult] = [
|
|
223
238
|
await this.#buildOrThrow("backend", { ...backendConfig, entrypoints: [backendEntryPoints[0]] }),
|
package/biome.base.json
CHANGED
|
@@ -137,7 +137,8 @@
|
|
|
137
137
|
"!**/*.spec.tsx",
|
|
138
138
|
"!**/*.constant.ts",
|
|
139
139
|
"!**/common/**",
|
|
140
|
-
"!**/env/**"
|
|
140
|
+
"!**/env/**",
|
|
141
|
+
"!{apps,libs}/*/native/**"
|
|
141
142
|
],
|
|
142
143
|
"plugins": ["./node_modules/@akanjs/devkit/lint/no-throw-raw-error.grit"]
|
|
143
144
|
},
|
|
@@ -225,6 +226,7 @@
|
|
|
225
226
|
"{apps,libs}/**/*.ts",
|
|
226
227
|
"{apps,libs}/**/*.tsx",
|
|
227
228
|
"!{apps,libs}/**/webkit/**",
|
|
229
|
+
"!{apps,libs}/*/native/**",
|
|
228
230
|
"!**/*.test.ts",
|
|
229
231
|
"!**/*.test.tsx",
|
|
230
232
|
"!**/*.spec.ts",
|
|
@@ -176,6 +176,71 @@ describe.skipIf(!CsrE2eHarness.enabled)("CSR page stack (minimal, /e2e/stack)",
|
|
|
176
176
|
expect(await csr.ticksOver("tab-a")).toBeGreaterThan(0);
|
|
177
177
|
}, 60_000);
|
|
178
178
|
|
|
179
|
+
test("a page that redirects after an await lands on the target, inside the target's own async layout", async () => {
|
|
180
|
+
await csr.open(TAB_B);
|
|
181
|
+
await csr.evaluate((next: string) => {
|
|
182
|
+
window.dispatchEvent(new CustomEvent("akan:sync-navigation", { detail: { href: next, kind: "push" } }));
|
|
183
|
+
}, "/e2e/stack/redirect?delay=120");
|
|
184
|
+
await csr.waitFor((pathname: string) => location.pathname.endsWith(pathname), {
|
|
185
|
+
args: ["/e2e/tabbed/home"],
|
|
186
|
+
timeout: 10_000,
|
|
187
|
+
});
|
|
188
|
+
await csr.waitFor(() => document.querySelectorAll('[data-e2e="tabbar"]').length === 1, { timeout: 10_000 });
|
|
189
|
+
await csr.waitFor(
|
|
190
|
+
() =>
|
|
191
|
+
(window as unknown as { __akanE2eProbes?: Record<string, { mounted?: boolean }> }).__akanE2eProbes?.home
|
|
192
|
+
?.mounted,
|
|
193
|
+
{ timeout: 10_000 },
|
|
194
|
+
);
|
|
195
|
+
expect(await csr.probe("home")).toMatchObject({ activity: "current", mounted: true });
|
|
196
|
+
expect(await csr.reloaded()).toBe(false);
|
|
197
|
+
}, 60_000);
|
|
198
|
+
|
|
199
|
+
test("an async render that throws is logged with its route instead of leaving a blank page in silence", async () => {
|
|
200
|
+
await csr.open("/e2e/stack/broken");
|
|
201
|
+
await csr.waitFor(() => true);
|
|
202
|
+
await Bun.sleep(500);
|
|
203
|
+
expect(
|
|
204
|
+
csr.consoleMessages().some((line) => /render of page \d+ of \S*\/e2e\/stack\/broken failed/.test(line)),
|
|
205
|
+
).toBe(true);
|
|
206
|
+
}, 60_000);
|
|
207
|
+
|
|
208
|
+
test("the memory frame trace records navigation without writing it to the console", async () => {
|
|
209
|
+
await csr.open(TAB_A);
|
|
210
|
+
await csr.evaluate(() => localStorage.setItem("akan:debug:frame", "memory"));
|
|
211
|
+
try {
|
|
212
|
+
await csr.open(TAB_A);
|
|
213
|
+
await csr.navigate(item("71"));
|
|
214
|
+
const events = await csr.evaluate(() =>
|
|
215
|
+
((window as { __AKAN_FRAME_TRACE__?: { event: string }[] }).__AKAN_FRAME_TRACE__ ?? []).map(
|
|
216
|
+
(entry) => entry.event,
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
expect(events).toContain("router.push");
|
|
220
|
+
expect(events).toContain("navigation.commit");
|
|
221
|
+
expect(csr.consoleMessages().some((line) => line.includes("[akan:frame:"))).toBe(false);
|
|
222
|
+
} finally {
|
|
223
|
+
await csr.evaluate(() => localStorage.removeItem("akan:debug:frame"));
|
|
224
|
+
}
|
|
225
|
+
}, 60_000);
|
|
226
|
+
|
|
227
|
+
test("a cached page whose layout redirected renders that layout again when it is back on screen", async () => {
|
|
228
|
+
await csr.evaluate(() => {
|
|
229
|
+
(globalThis as { __e2eSignedIn?: boolean }).__e2eSignedIn = false;
|
|
230
|
+
});
|
|
231
|
+
await csr.open("/e2e/gate/home");
|
|
232
|
+
await csr.waitFor((pathname: string) => location.pathname.endsWith(pathname), {
|
|
233
|
+
args: [TAB_B],
|
|
234
|
+
timeout: 10_000,
|
|
235
|
+
});
|
|
236
|
+
await csr.evaluate(() => {
|
|
237
|
+
(globalThis as { __e2eSignedIn?: boolean }).__e2eSignedIn = true;
|
|
238
|
+
});
|
|
239
|
+
await csr.navigate("/e2e/gate/home", "replace");
|
|
240
|
+
await csr.waitFor(() => document.querySelectorAll('[data-e2e="gate-tabbar"]').length === 1, { timeout: 10_000 });
|
|
241
|
+
expect(await csr.probe("gate-home")).toMatchObject({ activity: "current", mounted: true });
|
|
242
|
+
}, 60_000);
|
|
243
|
+
|
|
179
244
|
test("back returns to the previous entry without a reload", async () => {
|
|
180
245
|
await csr.open(TAB_A);
|
|
181
246
|
await csr.navigate(item("6"));
|
package/executors.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
2
3
|
import { lstat, mkdir, readFile, readlink, rm, stat, writeFile } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { AkanAppConfig } from "./akanConfig";
|
|
@@ -326,6 +327,37 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
326
327
|
expect((await stat(path.join(root, "dist/apps/demo/public"))).isDirectory()).toBe(true);
|
|
327
328
|
});
|
|
328
329
|
|
|
330
|
+
test("akan start clears the dev output and keeps native builds, update releases and the bin downloads", async () => {
|
|
331
|
+
const root = await makeTempRoot();
|
|
332
|
+
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
333
|
+
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
334
|
+
process.env.AKAN_PUBLIC_ENV = "local";
|
|
335
|
+
process.env.PORT_OFFSET = "0";
|
|
336
|
+
await writeJson(path.join(root, "package.json"), rootPackageJson());
|
|
337
|
+
await mkdir(path.join(root, "apps/startclean/page"), { recursive: true });
|
|
338
|
+
await writeFile(path.join(root, "apps/startclean/akan.config.ts"), "export default {};\n");
|
|
339
|
+
const akan = path.join(root, "apps/startclean/.akan");
|
|
340
|
+
const kept = [
|
|
341
|
+
"mobile/desktop/updates/macos-arm64/main.json",
|
|
342
|
+
"mobile/desktop/native/macos/App.app",
|
|
343
|
+
"cache/bin/ffmpeg",
|
|
344
|
+
];
|
|
345
|
+
const removed = [
|
|
346
|
+
"artifact/client/app.js",
|
|
347
|
+
"generated/dict/index.ts",
|
|
348
|
+
"cache/cssCandidates.json",
|
|
349
|
+
"desktop/server/main.js",
|
|
350
|
+
];
|
|
351
|
+
for (const file of [...kept, ...removed]) await writeText(path.join(akan, file), "x");
|
|
352
|
+
|
|
353
|
+
const app = AppExecutor.from(new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" }), "startclean");
|
|
354
|
+
await app.prepareCommand("start");
|
|
355
|
+
|
|
356
|
+
for (const file of kept) expect(existsSync(path.join(akan, file))).toBe(true);
|
|
357
|
+
for (const file of [...removed, "artifact", "generated", "desktop"])
|
|
358
|
+
expect(existsSync(path.join(akan, file))).toBe(false);
|
|
359
|
+
});
|
|
360
|
+
|
|
329
361
|
describe("syncPages", () => {
|
|
330
362
|
// `AppExecutor.from` memoises by name, so each test needs a name no other test has used.
|
|
331
363
|
const makeAppWithLibPages = async (
|
|
@@ -963,6 +995,17 @@ describe("WorkspaceExecutor listing", () => {
|
|
|
963
995
|
["@sample/tool", "akanjs", "zeta-tool"],
|
|
964
996
|
]);
|
|
965
997
|
});
|
|
998
|
+
|
|
999
|
+
test("never takes a scratch workspace under a local/ folder for a package, app or lib", async () => {
|
|
1000
|
+
const root = await makeTempRoot();
|
|
1001
|
+
await writeJson(path.join(root, "pkgs/@akanjs/devkit/package.json"), { name: "@akanjs/devkit" });
|
|
1002
|
+
await writeJson(path.join(root, "pkgs/@akanjs/devkit/local/akan-cli-x/package.json"), { name: "repo" });
|
|
1003
|
+
await writeText(path.join(root, "apps/portal/akan.config.ts"), "");
|
|
1004
|
+
await writeText(path.join(root, "libs/util/local/akan-cli-y/akan.config.ts"), "");
|
|
1005
|
+
|
|
1006
|
+
const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
|
|
1007
|
+
expect(await workspace.getExecs()).toEqual([["portal"], [], ["@akanjs/devkit"]]);
|
|
1008
|
+
});
|
|
966
1009
|
});
|
|
967
1010
|
|
|
968
1011
|
describe("SysExecutor module listing", () => {
|
package/executors.ts
CHANGED
|
@@ -790,7 +790,8 @@ export class WorkspaceExecutor extends Executor {
|
|
|
790
790
|
}
|
|
791
791
|
async #getDirHasFile(basePath: string, targetFilename: string) {
|
|
792
792
|
if (!(await FileSys.dirExists(basePath))) return [];
|
|
793
|
-
|
|
793
|
+
//? `local/` is gitignored scratch: a test's temporary workspace there has a package.json of its own.
|
|
794
|
+
const AVOID_DIRS = ["node_modules", "dist", "public", "webkit", "local"];
|
|
794
795
|
const getDirs = async (dirname: string, maxDepth = 3, results: string[] = [], prefix = "") => {
|
|
795
796
|
const dirs = await this.readdir(dirname);
|
|
796
797
|
await Promise.all(
|
|
@@ -1118,6 +1119,20 @@ export class AppExecutor extends SysExecutor {
|
|
|
1118
1119
|
static from(executor: SysExecutor | WorkspaceExecutor, name: string) {
|
|
1119
1120
|
return new AppExecutor({ workspace: executor instanceof WorkspaceExecutor ? executor : executor.workspace, name });
|
|
1120
1121
|
}
|
|
1122
|
+
//* Not dev output: native builds and the update releases waiting to be uploaded, and the downloaded `bin` sources.
|
|
1123
|
+
static readonly #keptOnStart = [path.join(".akan", "mobile"), path.join(".akan", "cache", "bin")];
|
|
1124
|
+
async #removeDevOutput(dir = ".akan") {
|
|
1125
|
+
const entries = await readDirEntries(this.getPath(dir)).catch(() => [] as string[]);
|
|
1126
|
+
await Promise.all(
|
|
1127
|
+
entries.map(async (name) => {
|
|
1128
|
+
const entry = path.join(dir, name);
|
|
1129
|
+
if (AppExecutor.#keptOnStart.includes(entry)) return;
|
|
1130
|
+
if (AppExecutor.#keptOnStart.some((kept) => kept.startsWith(`${entry}${path.sep}`)))
|
|
1131
|
+
return await this.#removeDevOutput(entry);
|
|
1132
|
+
await this.removeDir(entry);
|
|
1133
|
+
}),
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1121
1136
|
getEnv() {
|
|
1122
1137
|
return WorkspaceExecutor.getBaseDevEnv().env;
|
|
1123
1138
|
}
|
|
@@ -1174,7 +1189,7 @@ export class AppExecutor extends SysExecutor {
|
|
|
1174
1189
|
...(akanConfig.web.ssr ? [this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true })] : []),
|
|
1175
1190
|
]);
|
|
1176
1191
|
} else {
|
|
1177
|
-
await this
|
|
1192
|
+
await this.#removeDevOutput();
|
|
1178
1193
|
//? `akan start` keeps the full dev surface: the incremental builder is also the file watcher.
|
|
1179
1194
|
if (!akanConfig.web.ssr || !akanConfig.web.csr)
|
|
1180
1195
|
this.logger.verbose(
|