@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.
Files changed (38) hide show
  1. package/akanApp/akanApp.host.ts +22 -2
  2. package/akanConfig/akanBin.test.ts +62 -0
  3. package/akanConfig/akanBin.ts +78 -0
  4. package/akanConfig/akanConfig.test.ts +212 -2
  5. package/akanConfig/akanConfig.ts +93 -26
  6. package/akanConfig/index.ts +1 -0
  7. package/akanConfig/types.ts +7 -1
  8. package/applicationBuildRunner.test.ts +36 -2
  9. package/applicationBuildRunner.ts +21 -6
  10. package/biome.base.json +3 -1
  11. package/csrE2e/csrPageStack.e2e.test.ts +65 -0
  12. package/executors.test.ts +43 -0
  13. package/executors.ts +17 -2
  14. package/frontendBuild/csrDevModuleCompiler.test.ts +32 -0
  15. package/frontendBuild/csrDevModuleCompiler.ts +45 -2
  16. package/frontendBuild/ssrBaseArtifactBuilder.ts +7 -2
  17. package/integration/devStability.integration.test.ts +35 -0
  18. package/mobile/desktopBin.test.ts +177 -0
  19. package/mobile/desktopBin.ts +143 -0
  20. package/mobile/desktopServerStage.test.ts +129 -0
  21. package/mobile/desktopServerStage.ts +120 -0
  22. package/mobile/index.ts +3 -0
  23. package/mobile/mobileTarget.test.ts +50 -1
  24. package/mobile/mobileTarget.ts +20 -4
  25. package/mobile/nativeApp.test.ts +22 -1
  26. package/mobile/nativeApp.ts +123 -14
  27. package/mobile/nativeAppLine.test.ts +7 -0
  28. package/mobile/nativeAppLine.ts +8 -0
  29. package/mobile/nativeConfig.test.ts +215 -4
  30. package/mobile/nativeConfig.ts +117 -8
  31. package/mobile/nativePluginFolders.test.ts +81 -0
  32. package/mobile/nativePluginFolders.ts +60 -0
  33. package/package.json +2 -2
  34. package/transforms/serverEnvPlugin.test.ts +100 -0
  35. package/transforms/serverEnvPlugin.ts +41 -0
  36. package/types.ts +1 -0
  37. package/workspaceLayout.test.ts +5 -0
  38. package/workspaceLayout.ts +2 -0
@@ -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
- #makeDockerfile(): string {
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
- // The web env matches what was built (a deployment can only narrow it). File logging is off: a container's
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
- ENV PORT=8282
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: DeepPartial<LibConfigResult>, plugins: AkanPlugin[] = []) {
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 DeepPartial<LibConfigResult> & { plugins?: AkanPlugin[] };
691
+ const { plugins, ...config } = (resolved ?? {}) as LibConfigDeclaration & { plugins?: AkanPlugin[] };
625
692
  return new AkanLibConfig(lib, config, plugins ?? []);
626
693
  }
627
694
  }
@@ -1,2 +1,3 @@
1
+ export * from "./akanBin";
1
2
  export * from "./akanConfig";
2
3
  export * from "./types";
@@ -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 { AKAN_BACKEND_MINIFY, AKAN_OPTIONAL_BACKEND_EXTERNALS } from "./applicationBuildRunner";
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.#buildAppMeta());
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 #buildAppMeta() {
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`, akanConfig.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 backendConfig = { ...sharedConfig, plugins: [this.#createExternalSpecifiersPlugin(externalLibs)] };
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
- const AVOID_DIRS = ["node_modules", "dist", "public", "webkit"];
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.removeDir(".akan");
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(