@akanjs/devkit 3.0.0-alpha.64 → 3.0.0-alpha.66

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.
@@ -64,7 +64,7 @@ describe("AkanAppConfig", () => {
64
64
  expect(config.barrelImports).toEqual(
65
65
  expect.arrayContaining(["@apps/portal/ui", "@libs/shared/server", "akanjs/common", "akanjs/server"]),
66
66
  );
67
- expect(config.docker.content).toContain("ENV AKAN_PUBLIC_APP_NAME=portal");
67
+ expect(config.dockerfile).toContain("ENV AKAN_PUBLIC_APP_NAME=portal");
68
68
  expect(process.env.AKAN_PUBLIC_DEFAULT_LOCALE).toBe("en");
69
69
  });
70
70
 
@@ -128,9 +128,67 @@ describe("AkanAppConfig", () => {
128
128
  });
129
129
  expect(config.publicEnv).toEqual(["AKAN_PUBLIC_FEATURE"]);
130
130
  expect(config.optimizeImports).toContain("custom-icons");
131
- expect(config.docker.content).toContain('CMD ["bun","server.js"]');
132
- expect(config.docker.content).toContain("FROM oven/bun:amd64 AS amd64");
133
- expect(config.docker.content).toContain('RUN if [ "$TARGETARCH" = "arm64"');
131
+ expect(config.dockerfile).toContain('CMD ["bun","server.js"]');
132
+ expect(config.dockerfile).toContain("FROM oven/bun:amd64 AS amd64");
133
+ expect(config.dockerfile).toContain('RUN if [ "$TARGETARCH" = "arm64"');
134
+ });
135
+
136
+ test("defaults both web surfaces on and keeps the image free of web env overrides", () => {
137
+ const config = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
138
+
139
+ expect(config.web).toEqual({ ssr: true, csr: true });
140
+ expect(config.dockerfile).not.toContain("AKAN_SSR");
141
+ expect(config.dockerfile).not.toContain("AKAN_CSR");
142
+ });
143
+
144
+ test("bakes the disabled surface into the image env so the default matches what was built", () => {
145
+ const ssrOnly = new AkanAppConfig(app, [], packageJson, { web: { csr: false } }, baseDevEnv);
146
+ expect(ssrOnly.web).toEqual({ ssr: true, csr: false });
147
+ expect(ssrOnly.dockerfile).toContain("ENV AKAN_CSR=false");
148
+ expect(ssrOnly.dockerfile).not.toContain("ENV AKAN_SSR=false");
149
+
150
+ const apiOnly = new AkanAppConfig(app, [], packageJson, { web: false }, baseDevEnv);
151
+ expect(apiOnly.web).toEqual({ ssr: false, csr: false });
152
+ expect(apiOnly.dockerfile).toContain("ENV AKAN_SSR=false");
153
+ expect(apiOnly.dockerfile).toContain("ENV AKAN_CSR=false");
154
+
155
+ expect(new AkanAppConfig(app, [], packageJson, { web: true }, baseDevEnv).web).toEqual({ ssr: true, csr: true });
156
+ });
157
+
158
+ test("refuses a csr-less build that ships a mobile app", () => {
159
+ expect(
160
+ () => new AkanAppConfig(app, [], packageJson, { web: { csr: false }, mobile: { appName: "portal" } }, baseDevEnv),
161
+ ).toThrow("the Capacitor build ships that bundle");
162
+ });
163
+
164
+ test("installs only ca-certificates and tzdata in the default image", () => {
165
+ const config = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
166
+
167
+ expect(config.dockerfile).toContain(
168
+ "RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/*",
169
+ );
170
+ // The Chromium/ffmpeg toolchain moved to per-app `preRuns`; keeping it here paid for it in every image.
171
+ for (const dropped of ["libnss3", "ffmpeg", "build-essential", "redis", "xdg-utils"])
172
+ expect(config.dockerfile).not.toContain(dropped);
173
+ });
174
+
175
+ test("keeps a declared Dockerfile string verbatim", () => {
176
+ const dockerfile = "FROM oven/bun:1-slim\nCOPY . .\nCMD [\"bun\",\"main.js\"]";
177
+ const config = new AkanAppConfig(app, [], packageJson, { docker: dockerfile }, baseDevEnv);
178
+
179
+ expect(config.docker).toBe(dockerfile);
180
+ expect(config.dockerfile).toBe(dockerfile);
181
+ });
182
+
183
+ test("resolves the image parts, defaulting the base image and the command", () => {
184
+ const config = new AkanAppConfig(app, [], packageJson, { docker: { preRuns: ["echo hi"] } }, baseDevEnv);
185
+
186
+ expect(config.docker).toEqual({
187
+ image: "oven/bun:1-slim",
188
+ preRuns: ["echo hi"],
189
+ postRuns: [],
190
+ command: ["bun", "main.js"],
191
+ });
134
192
  });
135
193
 
136
194
  test("creates production package json and reports missing external versions", () => {
@@ -396,7 +454,7 @@ describe("AkanAppConfig lib externalLibs", () => {
396
454
  { externalLibs: ["@external/runtime"] },
397
455
  baseDevEnv,
398
456
  [],
399
- ["@external/runtime", "puppeteer"],
457
+ { externalLibs: ["@external/runtime", "puppeteer"], docker: { preRuns: [], postRuns: [] } },
400
458
  );
401
459
 
402
460
  expect(config.externalLibs).toEqual(["@external/runtime", "puppeteer"]);
@@ -429,6 +487,70 @@ describe("AkanAppConfig lib externalLibs", () => {
429
487
  });
430
488
  });
431
489
 
490
+
491
+ describe("AkanAppConfig lib docker runs", () => {
492
+ const libDocker = (preRuns: string[], postRuns: string[] = []) => ({
493
+ externalLibs: [],
494
+ docker: { preRuns, postRuns },
495
+ });
496
+
497
+ test("runs lib steps before the app's own, deduped", () => {
498
+ const config = new AkanAppConfig(
499
+ app,
500
+ ["shared"],
501
+ packageJson,
502
+ { docker: { preRuns: ["apt-get install -y ffmpeg", "echo app"], postRuns: ["echo app-post"] } },
503
+ baseDevEnv,
504
+ [],
505
+ libDocker(["apt-get install -y ffmpeg", "echo lib"], ["echo lib-post"]),
506
+ );
507
+
508
+ expect(config.docker).toMatchObject({
509
+ preRuns: ["apt-get install -y ffmpeg", "echo lib", "echo app"],
510
+ postRuns: ["echo lib-post", "echo app-post"],
511
+ });
512
+ expect(config.dockerfile).toContain("RUN echo lib\nRUN echo app\n");
513
+ expect(config.dockerfile.match(/RUN apt-get install -y ffmpeg/g)).toHaveLength(1);
514
+ });
515
+
516
+ test("drops them when the app hands over a whole Dockerfile", () => {
517
+ const config = new AkanAppConfig(
518
+ app,
519
+ ["shared"],
520
+ packageJson,
521
+ { docker: "FROM scratch" },
522
+ baseDevEnv,
523
+ [],
524
+ libDocker(["echo lib"]),
525
+ );
526
+
527
+ expect(config.dockerfile).toBe("FROM scratch");
528
+ });
529
+
530
+ test("reads them off every workspace lib config on load", async () => {
531
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "akan-config-libdocker-"));
532
+ try {
533
+ fs.mkdirSync(path.join(root, "apps/extapp"), { recursive: true });
534
+ fs.mkdirSync(path.join(root, "libs/extlib"), { recursive: true });
535
+ fs.writeFileSync(path.join(root, "apps/extapp/akan.config.ts"), "export default {};\n");
536
+ fs.writeFileSync(
537
+ path.join(root, "libs/extlib/akan.config.ts"),
538
+ "export default { docker: { preRuns: ['echo from-lib'], postRuns: [{ arm64: 'echo arm-only' }] } };\n",
539
+ );
540
+ fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "extrepo", version: "1.0.0" }));
541
+ fs.writeFileSync(path.join(root, ".env"), "AKAN_PUBLIC_REPO_NAME=extrepo\nAKAN_PUBLIC_SERVE_DOMAIN=ext.test\n");
542
+
543
+ const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: "extrepo" });
544
+ const config = await AppExecutor.from(workspace, "extapp").getConfig();
545
+
546
+ expect(config.dockerfile).toContain("RUN echo from-lib");
547
+ expect(config.dockerfile).toContain('RUN if [ "$TARGETARCH" = "arm64"');
548
+ } finally {
549
+ fs.rmSync(root, { recursive: true, force: true });
550
+ }
551
+ });
552
+ });
553
+
432
554
  describe("AkanLibConfig", () => {
433
555
  test("uses empty external libs by default and preserves explicit libs", () => {
434
556
  const lib = { name: "shared" } as never;
@@ -439,6 +561,15 @@ describe("AkanLibConfig", () => {
439
561
  };
440
562
  expect(new AkanLibConfig(lib, config).externalLibs).toEqual(["firebase-admin"]);
441
563
  });
564
+
565
+ test("defaults docker runs to empty lists and preserves declared ones", () => {
566
+ const lib = { name: "shared" } as never;
567
+ expect(new AkanLibConfig(lib, {}).docker).toEqual({ preRuns: [], postRuns: [] });
568
+ expect(new AkanLibConfig(lib, { docker: { preRuns: ["echo lib"] } }).docker).toEqual({
569
+ preRuns: ["echo lib"],
570
+ postRuns: [],
571
+ });
572
+ });
442
573
  });
443
574
 
444
575
  describe("AkanAppConfig.importConfigModule", () => {
@@ -11,13 +11,18 @@ import {
11
11
  type AkanMobileConfig,
12
12
  type AkanMobileTargetConfig,
13
13
  type AkanRouteConfig,
14
+ type AkanWebConfig,
15
+ type AkanWebOption,
14
16
  type AppConfigResult,
15
17
  type Arch,
16
18
  archs,
17
19
  type DatabaseMode,
18
20
  type DeepPartial,
19
21
  type DockerConfig,
22
+ type DockerOption,
23
+ type DockerRun,
20
24
  type LibConfigResult,
25
+ type LibDockerConfig,
21
26
  } from "./types";
22
27
 
23
28
  const DEFAULT_BARREL_IMPORTS = ["akanjs/webkit", "akanjs/common", "akanjs/ui", "akanjs/server"];
@@ -49,6 +54,7 @@ const DEFAULT_OPTIMIZE_IMPORTS = [
49
54
  "react-icons/*",
50
55
  ];
51
56
  const WORKSPACE_BARREL_FACETS = ["ui", "webkit", "common", "client", "server"] as const;
57
+ const DEFAULT_DOCKER_IMAGE = "oven/bun:1-slim";
52
58
  const SSR_RUNTIME_PACKAGES = ["react", "react-dom", "react-server-dom-webpack"] as const;
53
59
  // The firebase client (push tokens) and the Capacitor toolchain — `@capacitor/cli` (`npx cap`),
54
60
  // `@capacitor/assets` (`npx @capacitor/assets`) plus the `@capacitor/core`/`ios`/`android` runtime
@@ -163,11 +169,38 @@ const normalizeDeepLinks = (deepLinks: DeepPartial<AkanMobileTargetConfig["deepL
163
169
  } satisfies AkanMobileTargetConfig["deepLinks"];
164
170
  };
165
171
 
172
+ /** What `akan.config.ts` may write: the resolved shape made partial, with `docker` and `web` in their unions. */
173
+ type AppConfigDeclaration = Omit<DeepPartial<AppConfigResult>, "docker" | "web"> & {
174
+ docker?: DockerOption;
175
+ web?: AkanWebOption;
176
+ };
177
+
178
+ /** What the workspace's libs add to an app's build, read off each `libs/<lib>/akan.config.ts`. */
179
+ export interface LibContributions {
180
+ externalLibs: string[];
181
+ docker: LibDockerConfig;
182
+ }
183
+
184
+ const emptyLibContributions = (): LibContributions => ({
185
+ externalLibs: [],
186
+ docker: { preRuns: [], postRuns: [] },
187
+ });
188
+
189
+ /** First occurrence wins, so a step a lib and its app both declare becomes one layer. */
190
+ const dedupeDockerRuns = (runs: DockerRun[]): DockerRun[] => {
191
+ const byKey = new Map<string, DockerRun>();
192
+ for (const run of runs) byKey.set(typeof run === "string" ? run : JSON.stringify(run), run);
193
+ return [...byKey.values()];
194
+ };
195
+
166
196
  export class AkanAppConfig implements AppConfigResult {
167
197
  app: App;
168
198
  rootPackageJson: PackageJson;
169
199
  docker: DockerConfig;
200
+ /** The Dockerfile `akan build` writes: the declared string verbatim, or one assembled from the parts. */
201
+ dockerfile: string;
170
202
  defaultDatabaseMode: DatabaseMode;
203
+ web: AkanWebConfig;
171
204
  externalLibs: string[];
172
205
  barrelImports: string[];
173
206
  optimizeImports: string[];
@@ -192,10 +225,10 @@ export class AkanAppConfig implements AppConfigResult {
192
225
  app: App,
193
226
  libs: string[],
194
227
  rootPackageJson: PackageJson,
195
- config: DeepPartial<AppConfigResult>,
228
+ config: AppConfigDeclaration,
196
229
  baseDevEnv: BaseDevEnv,
197
230
  plugins: AkanPlugin[] = [],
198
- libExternalLibs: readonly string[] = [],
231
+ libContributions: LibContributions = emptyLibContributions(),
199
232
  ) {
200
233
  this.app = app;
201
234
  this.rootPackageJson = rootPackageJson;
@@ -204,7 +237,7 @@ export class AkanAppConfig implements AppConfigResult {
204
237
  this.plugins = plugins;
205
238
  this.#applyRoutes(config?.routes);
206
239
  this.defaultDatabaseMode = config?.defaultDatabaseMode ?? "single";
207
- this.externalLibs = [...new Set([...(config?.externalLibs ?? []), ...libExternalLibs])];
240
+ this.externalLibs = [...new Set([...(config?.externalLibs ?? []), ...libContributions.externalLibs])];
208
241
  this.barrelImports = [
209
242
  ...DEFAULT_BARREL_IMPORTS,
210
243
  ...WORKSPACE_BARREL_FACETS.map((facet) => `@apps/${app.name}/${facet}`),
@@ -221,7 +254,18 @@ export class AkanAppConfig implements AppConfigResult {
221
254
  this.syncPageLibs = (config?.syncPageLibs as string[] | boolean | undefined) ?? false;
222
255
  this.hasMobileConfig = Boolean(config.mobile);
223
256
  this.mobile = this.#resolveMobileConfig(config.mobile);
224
- this.docker = this.#makeDockerContent(config?.docker ?? {});
257
+ this.web = this.#resolveWebConfig(config.web);
258
+ this.docker = AkanAppConfig.#resolveDocker(config.docker, libContributions.docker);
259
+ this.dockerfile = this.#makeDockerfile();
260
+ }
261
+ #resolveWebConfig(web: AkanWebOption | undefined): AkanWebConfig {
262
+ const resolved = typeof web === "object" ? { ssr: true, csr: web.csr } : { ssr: web ?? true, csr: web ?? true };
263
+ // `akan build-ios` / `build-android` copy `dist/apps/<app>/csr/<target>.html` into the native project.
264
+ if (!resolved.csr && this.hasMobileConfig)
265
+ throw new Error(
266
+ `apps/${this.app.name}/akan.config.ts turns the CSR bundle off but declares mobile targets; the Capacitor build ships that bundle. Drop the mobile section or leave CSR on.`,
267
+ );
268
+ return resolved;
225
269
  }
226
270
  #resolveMobileConfig(mobile: DeepPartial<AkanMobileConfig> | undefined): AkanMobileConfig {
227
271
  const {
@@ -322,7 +366,7 @@ export class AkanAppConfig implements AppConfigResult {
322
366
  this.branches.forEach((domain) => void domains.add(`${basePath}-${domain}.${serveDomain}`));
323
367
  });
324
368
  }
325
- #getDockerRunScripts(runs: (string | { [key in Arch]?: string })[]) {
369
+ #getDockerRunScripts(runs: DockerRun[]) {
326
370
  return runs.map((run) => {
327
371
  if (typeof run === "string") return `RUN ${run}`;
328
372
  else
@@ -339,26 +383,31 @@ export class AkanAppConfig implements AppConfigResult {
339
383
  if (typeof image === "string") return `FROM ${image}`;
340
384
  else return archs.map((arch) => `FROM ${image[arch] ?? defaultImage} AS ${arch}`).join("\n");
341
385
  }
342
- #makeDockerContent(docker: DeepPartial<DockerConfig>): DockerConfig {
343
- if (docker.content)
344
- return {
345
- content: docker.content,
346
- image: {},
347
- preRuns: [],
348
- postRuns: [],
349
- command: [],
350
- };
351
- const preRunScripts = this.#getDockerRunScripts(docker.preRuns ?? []);
352
- const postRunScripts = this.#getDockerRunScripts(docker.postRuns ?? []);
353
-
354
- const imageScript = docker.image
355
- ? this.#getDockerImageScript(docker.image, "oven/bun:1-slim")
356
- : "FROM oven/bun:1-slim";
357
- const command = docker.command ?? ["bun", "main.js"];
358
- const content = `${imageScript}
386
+ /** A declared Dockerfile string is verbatim, so a lib's steps are dropped rather than silently unapplied. */
387
+ static #resolveDocker(docker: DockerOption | undefined, libDocker: LibDockerConfig): DockerConfig {
388
+ if (typeof docker === "string") return docker;
389
+ return {
390
+ image: docker?.image ?? DEFAULT_DOCKER_IMAGE,
391
+ preRuns: dedupeDockerRuns([...libDocker.preRuns, ...(docker?.preRuns ?? [])]),
392
+ postRuns: dedupeDockerRuns([...libDocker.postRuns, ...(docker?.postRuns ?? [])]),
393
+ command: docker?.command ?? ["bun", "main.js"],
394
+ };
395
+ }
396
+ #makeDockerfile(): string {
397
+ if (typeof this.docker === "string") return this.docker;
398
+ const { image, preRuns, postRuns, command } = this.docker;
399
+ const preRunScripts = this.#getDockerRunScripts(preRuns);
400
+ const postRunScripts = this.#getDockerRunScripts(postRuns);
401
+ const imageScript = this.#getDockerImageScript(image, DEFAULT_DOCKER_IMAGE);
402
+ // The image default matches what the build actually produced; a deployment narrows it further with its
403
+ // own env, and can never widen it past the artifacts that are in the image.
404
+ const webEnvLines = [
405
+ ...(this.web.ssr ? [] : ["ENV AKAN_SSR=false"]),
406
+ ...(this.web.csr ? [] : ["ENV AKAN_CSR=false"]),
407
+ ].join("\n");
408
+ return `${imageScript}
409
+ RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/*
359
410
  RUN ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime
360
- RUN apt-get update && apt-get upgrade -y
361
- RUN apt-get install -y --no-install-recommends git redis build-essential python3 ca-certificates fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils udev ffmpeg
362
411
  ARG TARGETARCH
363
412
  ${preRunScripts.join("\n")}
364
413
  RUN mkdir -p /workspace
@@ -377,15 +426,8 @@ ${this.basePaths.size ? `ENV AKAN_PUBLIC_BASE_PATHS=${[...this.basePaths].join("
377
426
  ENV AKAN_PUBLIC_DEFAULT_LOCALE=${this.i18n.defaultLocale}
378
427
  ENV AKAN_PUBLIC_LOCALES=${this.i18n.locales.join(",")}
379
428
  ENV AKAN_PUBLIC_OPERATION_MODE=cloud
380
-
429
+ ${webEnvLines}
381
430
  CMD [${command.map((c) => `"${c}"`).join(",")}]`;
382
- return {
383
- content,
384
- image: imageScript,
385
- preRuns: docker.preRuns ?? [],
386
- postRuns: docker.postRuns ?? [],
387
- command,
388
- };
389
431
  }
390
432
  static #importGeneration = 0;
391
433
  /**
@@ -412,24 +454,34 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
412
454
  app.workspace.getPackageJson(),
413
455
  ]);
414
456
  const resolved = typeof configImp === "function" ? configImp(app) : configImp;
415
- const { plugins, ...config } = (resolved ?? {}) as DeepPartial<AppConfigResult> & { plugins?: AkanPlugin[] };
416
- const libExternalLibs = await AkanAppConfig.#collectLibExternalLibs(app, libs, bustImportCache);
417
- return new AkanAppConfig(app, libs, rootPackageJson, config, baseDevEnv, plugins ?? [], libExternalLibs);
457
+ const { plugins, ...config } = (resolved ?? {}) as AppConfigDeclaration & { plugins?: AkanPlugin[] };
458
+ const libContributions = await AkanAppConfig.#collectLibContributions(app, libs, bustImportCache);
459
+ return new AkanAppConfig(app, libs, rootPackageJson, config, baseDevEnv, plugins ?? [], libContributions);
418
460
  }
419
461
  //* Every workspace lib is read, not just this app's lib deps: narrowing the set needs the dependency
420
462
  //* scan, and the incremental page rebundle re-reads this config on every file change.
421
- static async #collectLibExternalLibs(app: App, libs: string[], bustImportCache: boolean): Promise<string[]> {
463
+ static async #collectLibContributions(
464
+ app: App,
465
+ libs: string[],
466
+ bustImportCache: boolean,
467
+ ): Promise<LibContributions> {
422
468
  const libConfigs = await Promise.all(
423
469
  libs.map(async (libName) =>
424
470
  LibExecutor.from(app, libName)
425
471
  .getConfig({ refresh: bustImportCache })
426
472
  .catch((error: unknown) => {
427
- app.logger.warn(`Skipped libs/${libName}/akan.config.ts externalLibs: ${String(error)}`);
473
+ app.logger.warn(`Skipped libs/${libName}/akan.config.ts contributions: ${String(error)}`);
428
474
  return null;
429
475
  }),
430
476
  ),
431
477
  );
432
- return libConfigs.flatMap((libConfig) => libConfig?.externalLibs ?? []);
478
+ return {
479
+ externalLibs: libConfigs.flatMap((libConfig) => libConfig?.externalLibs ?? []),
480
+ docker: {
481
+ preRuns: libConfigs.flatMap((libConfig) => libConfig?.docker.preRuns ?? []),
482
+ postRuns: libConfigs.flatMap((libConfig) => libConfig?.docker.postRuns ?? []),
483
+ },
484
+ };
433
485
  }
434
486
  #resolveProductionDependencyVersion(lib: string) {
435
487
  const rootVersion = this.rootPackageJson.dependencies?.[lib] ?? this.rootPackageJson.devDependencies?.[lib];
@@ -539,11 +591,13 @@ function mergeImageConfig(config: Partial<AkanImageConfig> = {}): AkanImageConfi
539
591
  export class AkanLibConfig implements LibConfigResult {
540
592
  lib: Lib;
541
593
  externalLibs: string[];
594
+ docker: LibDockerConfig;
542
595
  /** Live-only: plugins declared in this lib's `akan.config.ts` (never serialized). */
543
596
  plugins: AkanPlugin[];
544
597
  constructor(lib: Lib, config: DeepPartial<LibConfigResult>, plugins: AkanPlugin[] = []) {
545
598
  this.lib = lib;
546
599
  this.externalLibs = config?.externalLibs ?? [];
600
+ this.docker = { preRuns: config?.docker?.preRuns ?? [], postRuns: config?.docker?.postRuns ?? [] };
547
601
  this.plugins = plugins;
548
602
  }
549
603
  static async from(lib: Lib, { bustImportCache = false }: { bustImportCache?: boolean } = {}) {
@@ -9,6 +9,8 @@ export type {
9
9
  AkanRouteConfig,
10
10
  AkanScanInfo,
11
11
  AkanSyncContext,
12
+ AkanWebConfig,
13
+ AkanWebOption,
12
14
  AppConfig,
13
15
  AppConfigInput,
14
16
  AppConfigResult,
@@ -17,10 +19,14 @@ export type {
17
19
  DatabaseMode,
18
20
  DeepPartial,
19
21
  DockerConfig,
22
+ DockerImageConfig,
23
+ DockerOption,
24
+ DockerRun,
20
25
  FileConventionScanResult,
21
26
  LibConfig,
22
27
  LibConfigInput,
23
28
  LibConfigResult,
29
+ LibDockerConfig,
24
30
  LibScanResult,
25
31
  MobileEnv,
26
32
  MobilePermission,
@@ -77,6 +77,7 @@ export class ApplicationBuildRunner {
77
77
  async build({ spinner = false }: BuildOptions = {}): Promise<BuildResult> {
78
78
  // serial build is needed because of Bun.build is unstable for parallel build
79
79
  const phaseOptions = { spinner };
80
+ const { web } = await this.#app.getConfig();
80
81
  await this.#runPhase("prepare", "Preparing output directory", () => this.#app.prepareCommand("build"), undefined, {
81
82
  spinner,
82
83
  });
@@ -91,18 +92,20 @@ export class ApplicationBuildRunner {
91
92
  await this.#runPhase(
92
93
  "ssr",
93
94
  "Building SSR route artifacts",
94
- () => this.#buildSsr(),
95
+ async () => (web.ssr ? await this.#buildSsr() : null),
95
96
  (result) =>
96
97
  result
97
98
  ? `${result.allRoutes.manifest.routeIds.length} routes, ${result.allRoutes.manifest.knownEntries.length} entries`
98
- : "skipped",
99
+ : web.ssr
100
+ ? "skipped"
101
+ : "disabled by akan.config.ts web.ssr",
99
102
  phaseOptions,
100
103
  );
101
104
  await this.#runPhase(
102
105
  "csr",
103
106
  "Building CSR assets",
104
- () => this.#buildCsr(),
105
- (result) => result?.outputDir ?? "skipped",
107
+ async () => (web.csr ? await this.#buildCsr() : null),
108
+ (result) => result?.outputDir ?? (web.csr ? "skipped" : "disabled by akan.config.ts web.csr"),
106
109
  phaseOptions,
107
110
  );
108
111
  await this.#runPhase(
@@ -165,7 +168,7 @@ export class ApplicationBuildRunner {
165
168
  const akanConfig = await this.#app.getConfig();
166
169
  await Promise.all([
167
170
  this.#app.dist.writeJson("package.json", akanConfig.getProductionPackageJson()),
168
- this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`, akanConfig.docker.content),
171
+ this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`, akanConfig.dockerfile),
169
172
  ]);
170
173
  }
171
174
 
@@ -174,6 +177,7 @@ export class ApplicationBuildRunner {
174
177
  const backendExternals = [
175
178
  ...new Set([...akanConfig.externalLibs, ...SSR_RENDER_EXTERNALS, ...AKAN_OPTIONAL_BACKEND_EXTERNALS]),
176
179
  ];
180
+ const { web } = akanConfig;
177
181
  const backendEntryPoints = [`${this.#app.cwdPath}/main.ts`, `${this.#app.cwdPath}/server.ts`];
178
182
  for (const entrypoint of backendEntryPoints) {
179
183
  if (!(await Bun.file(entrypoint).exists())) throw new Error(`Backend entrypoint not found: ${entrypoint}`);
@@ -187,17 +191,20 @@ export class ApplicationBuildRunner {
187
191
  define: { "process.env.NODE_ENV": JSON.stringify("production") },
188
192
  plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
189
193
  });
190
- const rscWorkerResult = await this.#buildOrThrow("rsc-worker", {
191
- entrypoints: [this.#resolveRscWorkerBuildEntry()],
192
- outdir: this.#app.dist.cwdPath,
193
- target: "bun",
194
- minify: true,
195
- naming: { entry: "[name].[ext]", chunk: "chunk-[hash].[ext]" },
196
- conditions: ["react-server"],
197
- // `akan build` must embed production react-server-dom regardless of the shell's NODE_ENV.
198
- define: { "process.env.NODE_ENV": JSON.stringify("production") },
199
- plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
200
- });
194
+ // Nothing spawns the RSC worker without SSR, so an api-only image does not carry it.
195
+ const rscWorkerResult = web.ssr
196
+ ? await this.#buildOrThrow("rsc-worker", {
197
+ entrypoints: [this.#resolveRscWorkerBuildEntry()],
198
+ outdir: this.#app.dist.cwdPath,
199
+ target: "bun",
200
+ minify: true,
201
+ naming: { entry: "[name].[ext]", chunk: "chunk-[hash].[ext]" },
202
+ conditions: ["react-server"],
203
+ // `akan build` must embed production react-server-dom regardless of the shell's NODE_ENV.
204
+ define: { "process.env.NODE_ENV": JSON.stringify("production") },
205
+ plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
206
+ })
207
+ : null;
201
208
  const consoleRuntimeResult = await this.#buildOrThrow("console-runtime", {
202
209
  entrypoints: [this.#resolveConsoleRuntimeBuildEntry()],
203
210
  outdir: this.#app.dist.cwdPath,
@@ -208,8 +215,9 @@ export class ApplicationBuildRunner {
208
215
  });
209
216
  await this.#writeConsoleShim();
210
217
  return {
211
- entrypoints: backendEntryPoints.length + 2,
212
- outputs: backendResult.outputs.length + rscWorkerResult.outputs.length + consoleRuntimeResult.outputs.length + 1,
218
+ entrypoints: backendEntryPoints.length + (rscWorkerResult ? 2 : 1),
219
+ outputs:
220
+ backendResult.outputs.length + (rscWorkerResult?.outputs.length ?? 0) + consoleRuntimeResult.outputs.length + 1,
213
221
  };
214
222
  }
215
223
 
package/executors.ts CHANGED
@@ -1371,11 +1371,20 @@ export class AppExecutor extends SysExecutor {
1371
1371
  await Promise.all([this.dist.mkdir("private"), this.dist.mkdir("public")]);
1372
1372
  //* Lib assets are symlinks in the app dir (see syncAssets). dist is the docker build context and the
1373
1373
  //* release tarball root, neither of which follows a link out of itself, so materialize them here.
1374
+ //* `public/` has exactly one reader — the web router's catch-all — so an api-only build leaves it out.
1374
1375
  await Promise.all([
1375
1376
  this.cp("private", `${this.dist.cwdPath}/private`, { dereference: true }),
1376
- this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true }),
1377
+ ...(akanConfig.web.ssr ? [this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true })] : []),
1377
1378
  ]);
1378
- } else await this.removeDir(".akan");
1379
+ } else {
1380
+ await this.removeDir(".akan");
1381
+ //? `web` is a build declaration: `akan start` keeps the full dev surface because the incremental
1382
+ //? builder is also the file watcher, so switching it off would take server-code HMR with it.
1383
+ if (!akanConfig.web.ssr || !akanConfig.web.csr)
1384
+ this.logger.warn(
1385
+ `akan.config.ts disables web.${!akanConfig.web.ssr ? "ssr" : "csr"}; \`akan start\` still serves it, \`akan build\` will not`,
1386
+ );
1387
+ }
1379
1388
  //? `akan start` is the dev server, and it must say so rather than inherit an answer. Bun auto-loads the
1380
1389
  //? workspace `.env`, so NODE_ENV=production can reach this process without ever being exported in a shell —
1381
1390
  //? and a dev server that believes it is production serves from the production route cache, whose every
@@ -77,6 +77,7 @@ export class SsrBaseArtifactBuilder {
77
77
  branches: [...akanConfig.branches],
78
78
  i18n: akanConfig.i18n,
79
79
  imageConfig: akanConfig.images,
80
+ web: akanConfig.web,
80
81
  deepLinkAssociations: Object.values(akanConfig.mobile.targets)
81
82
  .filter((target) => (target.deepLinks?.domains?.length ?? 0) > 0)
82
83
  .map((target) => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-alpha.64",
3
+ "version": "3.0.0-alpha.66",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -45,7 +45,7 @@
45
45
  "@langchain/openai": "^1.4.6",
46
46
  "@tailwindcss/node": "^4.3.0",
47
47
  "@trapezedev/project": "^7.1.4",
48
- "akanjs": "3.0.0-alpha.64",
48
+ "akanjs": "3.0.0-alpha.66",
49
49
  "chalk": "^5.6.2",
50
50
  "commander": "^14.0.3",
51
51
  "dayjs": "^1.11.20",
package/scanInfo.ts CHANGED
@@ -9,6 +9,7 @@ import type {
9
9
  PkgScanResult,
10
10
  ScanResult,
11
11
  } from "./akanConfig";
12
+ import { AkanAppConfig } from "./akanConfig";
12
13
  import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "./executors";
13
14
  import { isAllowedLibFacetRootFile, rootAllowedDirs, rootAllowedFiles } from "./workspaceLayout";
14
15
 
@@ -285,12 +286,11 @@ class ScanInfo {
285
286
  }),
286
287
  ]);
287
288
  const routes = exec.type === "lib" ? [] : await (exec as AppExecutor).getPageKeys();
288
- const scanResult: AppScanResult | LibScanResult = {
289
+ const common = {
289
290
  name: exec.name,
290
291
  type: exec.type,
291
292
  repoName: exec.workspace.repoName,
292
293
  serveDomain: WorkspaceExecutor.getBaseDevEnv(path.join(exec.workspace.workspaceRoot, ".env")).serveDomain,
293
- akanConfig,
294
294
  files,
295
295
  libDeps,
296
296
  pkgDeps,
@@ -298,6 +298,11 @@ class ScanInfo {
298
298
  devDependencies: npmDevDeps.filter((dep) => !isAkanFrameworkDependency(dep)),
299
299
  routes,
300
300
  };
301
+ //? `exec.type` and the resolved config class are correlated, which the union alone cannot express.
302
+ const scanResult: AppScanResult | LibScanResult =
303
+ akanConfig instanceof AkanAppConfig
304
+ ? ({ ...common, akanConfig } satisfies AppScanResult)
305
+ : ({ ...common, akanConfig } satisfies LibScanResult);
301
306
  return scanResult;
302
307
  }
303
308
 
@@ -462,7 +467,7 @@ export class LibInfo extends ScanInfo {
462
467
  LibInfo.libInfos.set(libName, await LibInfo.fromExecutor(libExecutor));
463
468
  }),
464
469
  );
465
- const libInfo = new LibInfo(exec, scanResult);
470
+ const libInfo = new LibInfo(exec, scanResult as LibScanResult);
466
471
  LibInfo.libInfos.set(exec.name, libInfo);
467
472
  LibInfo.#sortedLibIndices = null;
468
473
  return libInfo;