@akanjs/devkit 3.0.0-alpha.63 → 3.0.0-alpha.65

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.
@@ -133,6 +133,45 @@ describe("AkanAppConfig", () => {
133
133
  expect(config.docker.content).toContain('RUN if [ "$TARGETARCH" = "arm64"');
134
134
  });
135
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.docker.content).not.toContain("AKAN_SSR");
141
+ expect(config.docker.content).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.docker.content).toContain("ENV AKAN_CSR=false");
148
+ expect(ssrOnly.docker.content).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.docker.content).toContain("ENV AKAN_SSR=false");
153
+ expect(apiOnly.docker.content).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.docker.content).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.docker.content).not.toContain(dropped);
173
+ });
174
+
136
175
  test("creates production package json and reports missing external versions", () => {
137
176
  const config = new AkanAppConfig(app, [], packageJson, { externalLibs: ["@external/runtime"] }, baseDevEnv);
138
177
 
@@ -11,6 +11,8 @@ 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,
@@ -163,11 +165,15 @@ const normalizeDeepLinks = (deepLinks: DeepPartial<AkanMobileTargetConfig["deepL
163
165
  } satisfies AkanMobileTargetConfig["deepLinks"];
164
166
  };
165
167
 
168
+ /** What `akan.config.ts` may write: the resolved shape made partial, with `web` in its authoring union. */
169
+ type AppConfigDeclaration = Omit<DeepPartial<AppConfigResult>, "web"> & { web?: AkanWebOption };
170
+
166
171
  export class AkanAppConfig implements AppConfigResult {
167
172
  app: App;
168
173
  rootPackageJson: PackageJson;
169
174
  docker: DockerConfig;
170
175
  defaultDatabaseMode: DatabaseMode;
176
+ web: AkanWebConfig;
171
177
  externalLibs: string[];
172
178
  barrelImports: string[];
173
179
  optimizeImports: string[];
@@ -192,7 +198,7 @@ export class AkanAppConfig implements AppConfigResult {
192
198
  app: App,
193
199
  libs: string[],
194
200
  rootPackageJson: PackageJson,
195
- config: DeepPartial<AppConfigResult>,
201
+ config: AppConfigDeclaration,
196
202
  baseDevEnv: BaseDevEnv,
197
203
  plugins: AkanPlugin[] = [],
198
204
  libExternalLibs: readonly string[] = [],
@@ -221,8 +227,18 @@ export class AkanAppConfig implements AppConfigResult {
221
227
  this.syncPageLibs = (config?.syncPageLibs as string[] | boolean | undefined) ?? false;
222
228
  this.hasMobileConfig = Boolean(config.mobile);
223
229
  this.mobile = this.#resolveMobileConfig(config.mobile);
230
+ this.web = this.#resolveWebConfig(config.web);
224
231
  this.docker = this.#makeDockerContent(config?.docker ?? {});
225
232
  }
233
+ #resolveWebConfig(web: AkanWebOption | undefined): AkanWebConfig {
234
+ const resolved = typeof web === "object" ? { ssr: true, csr: web.csr } : { ssr: web ?? true, csr: web ?? true };
235
+ // `akan build-ios` / `build-android` copy `dist/apps/<app>/csr/<target>.html` into the native project.
236
+ if (!resolved.csr && this.hasMobileConfig)
237
+ throw new Error(
238
+ `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.`,
239
+ );
240
+ return resolved;
241
+ }
226
242
  #resolveMobileConfig(mobile: DeepPartial<AkanMobileConfig> | undefined): AkanMobileConfig {
227
243
  const {
228
244
  targets: rawTargets,
@@ -355,10 +371,15 @@ export class AkanAppConfig implements AppConfigResult {
355
371
  ? this.#getDockerImageScript(docker.image, "oven/bun:1-slim")
356
372
  : "FROM oven/bun:1-slim";
357
373
  const command = docker.command ?? ["bun", "main.js"];
374
+ // The image default matches what the build actually produced; a deployment narrows it further with its
375
+ // own env, and can never widen it past the artifacts that are in the image.
376
+ const webEnvLines = [
377
+ ...(this.web.ssr ? [] : ["ENV AKAN_SSR=false"]),
378
+ ...(this.web.csr ? [] : ["ENV AKAN_CSR=false"]),
379
+ ].join("\n");
358
380
  const content = `${imageScript}
381
+ 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
382
  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
383
  ARG TARGETARCH
363
384
  ${preRunScripts.join("\n")}
364
385
  RUN mkdir -p /workspace
@@ -377,7 +398,7 @@ ${this.basePaths.size ? `ENV AKAN_PUBLIC_BASE_PATHS=${[...this.basePaths].join("
377
398
  ENV AKAN_PUBLIC_DEFAULT_LOCALE=${this.i18n.defaultLocale}
378
399
  ENV AKAN_PUBLIC_LOCALES=${this.i18n.locales.join(",")}
379
400
  ENV AKAN_PUBLIC_OPERATION_MODE=cloud
380
-
401
+ ${webEnvLines}
381
402
  CMD [${command.map((c) => `"${c}"`).join(",")}]`;
382
403
  return {
383
404
  content,
@@ -412,7 +433,7 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
412
433
  app.workspace.getPackageJson(),
413
434
  ]);
414
435
  const resolved = typeof configImp === "function" ? configImp(app) : configImp;
415
- const { plugins, ...config } = (resolved ?? {}) as DeepPartial<AppConfigResult> & { plugins?: AkanPlugin[] };
436
+ const { plugins, ...config } = (resolved ?? {}) as AppConfigDeclaration & { plugins?: AkanPlugin[] };
416
437
  const libExternalLibs = await AkanAppConfig.#collectLibExternalLibs(app, libs, bustImportCache);
417
438
  return new AkanAppConfig(app, libs, rootPackageJson, config, baseDevEnv, plugins ?? [], libExternalLibs);
418
439
  }
@@ -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,
@@ -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(
@@ -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.63",
3
+ "version": "3.0.0-alpha.65",
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.63",
48
+ "akanjs": "3.0.0-alpha.65",
49
49
  "chalk": "^5.6.2",
50
50
  "commander": "^14.0.3",
51
51
  "dayjs": "^1.11.20",