@akanjs/devkit 3.0.0-beta.28 → 3.0.0-beta.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { IncrementalBuilderHost } from "../incrementalBuilder";
4
+ import { createTempApp, isolateEnv, tempRoots, writeText } from "../testHelpers";
2
5
  import { AkanAppHost } from "./akanApp.host";
6
+ import type { DevHostEvent } from "./devHostPolicy";
3
7
 
4
8
  describe("readProcessRssBytes", () => {
5
9
  test("reads this process's own rss", async () => {
@@ -14,3 +18,54 @@ describe("readProcessRssBytes", () => {
14
18
  expect(await AkanAppHost.readProcessRssBytes(2_147_483_646)).toBeNull();
15
19
  });
16
20
  });
21
+
22
+ describe("a gateway whose replica crash-loops", () => {
23
+ isolateEnv({ AKAN_PUBLIC_REPO_NAME: "repo", AKAN_PUBLIC_SERVE_DOMAIN: "localhost", AKAN_PUBLIC_ENV: "local" });
24
+ const track = tempRoots();
25
+ const crashLoop = "Backend replica 0/federation failed 3 consecutive boots; waiting for a code change to retry: boom";
26
+ const readyBuilder = {
27
+ status: "ready",
28
+ patcherOff: false,
29
+ start: ({ onReady }: { onReady?: () => void }) => onReady?.(),
30
+ send: () => true,
31
+ stop: () => undefined,
32
+ } as unknown as IncrementalBuilderHost;
33
+
34
+ test("fails the dev server at once, and leaves it running for the fix", async () => {
35
+ const { root, app } = track(await createTempApp("demo"));
36
+ await writeText(
37
+ path.join(root, "apps/demo/main.ts"),
38
+ [
39
+ `process.send?.({ type: "build-status", data: { generation: -1, phase: "backend", ok: false, files: [], message: ${JSON.stringify(crashLoop)} } });`,
40
+ "setInterval(() => undefined, 1_000);",
41
+ "",
42
+ ].join("\n"),
43
+ );
44
+ const create = IncrementalBuilderHost.create;
45
+ IncrementalBuilderHost.create = async () => readyBuilder;
46
+ const events: DevHostEvent[] = [];
47
+ const failed = Promise.withResolvers<void>();
48
+ const host = new AkanAppHost(app, {
49
+ env: { ...process.env } as Record<string, string>,
50
+ onDevEvent: (event) => {
51
+ events.push(event);
52
+ if ("state" in event && event.state === "failed") failed.resolve();
53
+ },
54
+ });
55
+ let beforeStop: DevHostEvent[] = [];
56
+ try {
57
+ await host.start();
58
+ await failed.promise;
59
+ await Bun.sleep(300);
60
+ beforeStop = [...events];
61
+ } finally {
62
+ await host.stop();
63
+ IncrementalBuilderHost.create = create;
64
+ }
65
+
66
+ expect(beforeStop).toEqual([
67
+ { app: "demo", state: "starting" },
68
+ { app: "demo", state: "failed", detail: crashLoop },
69
+ ]);
70
+ });
71
+ });
@@ -245,6 +245,8 @@ export class AkanAppHost {
245
245
  message: msg.data.message,
246
246
  });
247
247
  this.#sendOrQueueBuildStatus(status);
248
+ //? A replica crash loop leaves the gateway up, waiting for an edit: no exit reaches the host's own give-up.
249
+ if (!status.ok) this.#emitDevEvent("failed", status.message);
248
250
  return;
249
251
  }
250
252
  if (backendMsgTypeSet.has(msg.type)) this.#sendToBuilder(msg);
@@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { AkanAppConfig } from "../akanConfig";
5
- import { tempDirs } from "../testHelpers";
5
+ import type { App } from "../commandDecorators";
6
+ import { tempDirs, writeText as write } from "../testHelpers";
6
7
  import type { PackageJson } from "../types";
7
8
  import { DesktopServerStage } from "./desktopServerStage";
8
9
  import { NativeApi } from "./nativeApi";
@@ -85,6 +86,80 @@ describe("DesktopServerStage", () => {
85
86
  });
86
87
  });
87
88
 
89
+ const stageApp = async (spawn: (...args: unknown[]) => Promise<string> = async () => "") => {
90
+ const root = await makeTempRoot();
91
+ const dist = path.join(root, "dist/apps/portal");
92
+ const config = appConfig();
93
+ const app = {
94
+ cwdPath: path.join(root, "apps/portal"),
95
+ dist: { cwdPath: dist },
96
+ getConfig: async () => config,
97
+ getScanInfo: () => ({ getLibs: () => [] }),
98
+ logger: { info: () => undefined, warn: () => undefined },
99
+ spawn,
100
+ } as unknown as App;
101
+ return { dist, app, stage: new DesktopServerStage(app) };
102
+ };
103
+
104
+ test("carries everything the backend build wrote beside main.js but what only the image reads", async () => {
105
+ const { dist, stage } = await stageApp();
106
+ const carried = [
107
+ "main.js",
108
+ "server.js",
109
+ "chunk-5eap2n9b.js",
110
+ "akan.build.json",
111
+ "better-sqlite3-a1b2c3d4.node",
112
+ "engine-e5f6a7b8.wasm",
113
+ "model-c9d0e1f2.bin",
114
+ "private/service-account.json",
115
+ ];
116
+ const imageOnly = [
117
+ "Dockerfile",
118
+ "package.json",
119
+ "rscWorker.js",
120
+ "console.js",
121
+ "console-runtime.js",
122
+ "csr/index.html",
123
+ "public/favicon.ico",
124
+ ".akan/artifact/base-artifact.json",
125
+ ];
126
+ for (const file of [...carried, ...imageOnly]) await write(path.join(dist, file), file);
127
+
128
+ await stage.prepare("main");
129
+
130
+ const staged = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: stage.dir, dot: true }));
131
+ expect(staged.map((file) => file.split(path.sep).join("/")).sort()).toEqual(carried.sort());
132
+ expect(await Bun.file(path.join(stage.dir, "better-sqlite3-a1b2c3d4.node")).text()).toBe(
133
+ "better-sqlite3-a1b2c3d4.node",
134
+ );
135
+ });
136
+
137
+ test("installs from the config alone, offline when Bun's cache holds the packages", async () => {
138
+ const spawned: unknown[][] = [];
139
+ const { stage } = await stageApp(async (...args) => {
140
+ spawned.push(args);
141
+ return "";
142
+ });
143
+
144
+ await stage.install();
145
+
146
+ expect(spawned).toEqual([[process.execPath, ["install", "--production", "--prefer-offline"], { cwd: stage.dir }]]);
147
+ const installed = (await Bun.file(path.join(stage.dir, "package.json")).json()) as PackageJson;
148
+ expect(Object.keys(installed.dependencies ?? {}).sort()).toEqual(["react", "react-dom"]);
149
+ });
150
+
151
+ test("an install that fails says it needs the registry or a Bun cache that holds the packages", async () => {
152
+ const { stage } = await stageApp(async () => {
153
+ throw new Error("error: GET https://registry.npmjs.org/scheduler - ConnectionRefused");
154
+ });
155
+
156
+ const failed = stage.install();
157
+
158
+ await expect(failed).rejects.toThrow(
159
+ "it needs the npm registry, or a Bun cache that already holds every one of them.\nerror: GET https://registry.npmjs.org/scheduler",
160
+ );
161
+ });
162
+
88
163
  test("refuses an app whose database modes leave out single", () => {
89
164
  expect(() => DesktopServerStage.assertCarriable(appConfig({ database: { modes: ["cluster"] } }))).toThrow(
90
165
  "only database mode single runs",
@@ -16,8 +16,18 @@ export interface DesktopServerBundle {
16
16
  //* `bun install --production` does. It is staged outside dist: installing there would put host packages into the
17
17
  //* image's `COPY . .`.
18
18
  export class DesktopServerStage {
19
- //? The RSC worker's and the akan console's bundles: an API-only server starts neither.
20
- static readonly skippedFiles = new Set(["rscWorker.js", "console.js", "console-runtime.js"]);
19
+ //? An API-only server starts neither the RSC worker nor the akan console and serves no CSR, public/ or SSR artifact,
20
+ //? and `install` wrote its own package.json. The rest of dist is the server's: its chunks, a `.node`, a `.wasm`.
21
+ static readonly uncarried = new Set([
22
+ "Dockerfile",
23
+ "package.json",
24
+ "rscWorker.js",
25
+ "console.js",
26
+ "console-runtime.js",
27
+ "csr",
28
+ "public",
29
+ ".akan",
30
+ ]);
21
31
  //? Only the RSC worker loads it, and its peer webpack makes 35 of the 43 MB an install would add.
22
32
  static readonly rscRenderer = "react-server-dom-webpack";
23
33
 
@@ -88,29 +98,37 @@ export class DesktopServerStage {
88
98
  return { ...built, dependencies: Object.fromEntries(dependencies) };
89
99
  }
90
100
 
91
- async prepare(environment: MobileEnv): Promise<DesktopServerBundle> {
101
+ //* The packages come from the config alone, so they install before `akan build` runs: a machine that cannot install
102
+ //* them stops at once instead of after the whole build.
103
+ async install() {
92
104
  const config = await this.app.getConfig();
93
105
  DesktopServerStage.assertCarriable(config);
94
- const dist = this.app.dist.cwdPath;
95
106
  await rm(this.dir, { recursive: true, force: true });
96
107
  await mkdir(this.dir, { recursive: true });
97
- const entries = await readdir(dist, { withFileTypes: true });
98
- for (const entry of entries) {
99
- const carried =
100
- (entry.isFile() &&
101
- ((entry.name.endsWith(".js") && !DesktopServerStage.skippedFiles.has(entry.name)) ||
102
- entry.name === "akan.build.json")) ||
103
- (entry.isDirectory() && entry.name === "private");
104
- if (carried) await cp(path.join(dist, entry.name), path.join(this.dir, entry.name), { recursive: true });
105
- }
106
- const built = (await Bun.file(path.join(dist, "package.json")).json()) as PackageJson;
107
108
  await writeFile(
108
109
  path.join(this.dir, "package.json"),
109
- JSON.stringify(DesktopServerStage.packageJson(config, built), null, 2),
110
+ JSON.stringify(DesktopServerStage.packageJson(config, config.getProductionPackageJson()), null, 2),
110
111
  );
111
- await this.app.spawn(process.execPath, ["install", "--production"], { cwd: this.dir });
112
+ try {
113
+ await this.app.spawn(process.execPath, ["install", "--production", "--prefer-offline"], { cwd: this.dir });
114
+ } catch (error) {
115
+ throw new Error(
116
+ `The desktop app's server could not install its packages in ${this.dir}: it needs the npm registry, or a Bun cache that already holds every one of them.\n${error instanceof Error ? error.message : String(error)}`,
117
+ { cause: error },
118
+ );
119
+ }
112
120
  //? Launcher links only: a link out of the app bundle breaks its signature, and the server runs none of them.
113
121
  await rm(path.join(this.dir, "node_modules", ".bin"), { recursive: true, force: true });
122
+ }
123
+
124
+ //? Copies the build beside what `install` put in the stage, so it runs after both.
125
+ async prepare(environment: MobileEnv): Promise<DesktopServerBundle> {
126
+ const config = await this.app.getConfig();
127
+ const dist = this.app.dist.cwdPath;
128
+ await mkdir(this.dir, { recursive: true });
129
+ for (const name of await readdir(dist))
130
+ if (!DesktopServerStage.uncarried.has(name))
131
+ await cp(path.join(dist, name), path.join(this.dir, name), { recursive: true });
114
132
  const scanInfo = this.app.getScanInfo({ allowEmpty: true }) ?? (await this.app.scan({ write: false }));
115
133
  const carried = [...DesktopBin.select(config, scanInfo.getLibs()).keys()];
116
134
  const notice = DesktopServerStage.imageStepsNotice(config, carried);
@@ -3,9 +3,12 @@ import path from "node:path";
3
3
  import type { AkanPlugin } from "akanjs";
4
4
  import type { AkanMobileTargetConfig } from "../akanConfig";
5
5
  import type { App } from "../commandDecorators";
6
+ import { tempDirs, writeText } from "../testHelpers";
6
7
  import { NativeApp } from "./nativeApp";
7
8
  import { NativeConfig } from "./nativeConfig";
8
9
 
10
+ const makeTempRoot = tempDirs("akan-native-app-");
11
+
9
12
  const target = (config: Partial<AkanMobileTargetConfig> = {}) => ({
10
13
  name: config.name ?? "default",
11
14
  config: {
@@ -39,6 +42,33 @@ describe("NativeApp", () => {
39
42
  expect(admin.web.dir).toBe(path.join(appDir, ".akan/mobile/admin/web"));
40
43
  expect(admin.outDir("ios")).toBe(path.join(appDir, ".akan/mobile/admin/native/ios"));
41
44
  expect(admin.outDir("android")).toBe(path.join(appDir, ".akan/mobile/admin/native/android"));
45
+ expect(admin.devOutDir("macos")).toBe(path.join(appDir, ".akan/mobile/admin/dev/macos"));
46
+ });
47
+
48
+ test("a dev build bundles no page a release build left, and builds apart from the release it would empty", async () => {
49
+ const app = {
50
+ ...fakeApp(),
51
+ cwdPath: await makeTempRoot(),
52
+ logger: { info: () => undefined, debug: () => undefined, warn: () => undefined },
53
+ } as unknown as App;
54
+ const nativeApp = new NativeApp(app, target());
55
+ await writeText(path.join(nativeApp.web.dir, "index.html"), "<html>the last release</html>");
56
+ const seen: { outDir?: string; leftover: boolean }[] = [];
57
+ nativeApp.prepare = async () =>
58
+ ({
59
+ config: {},
60
+ api: {
61
+ dev: async ({ outDir }: { outDir?: string }) => {
62
+ seen.push({ outDir, leftover: await Bun.file(path.join(nativeApp.web.dir, "index.html")).exists() });
63
+ return { gateway: "http://127.0.0.1:9", exited: Promise.resolve(0), stop: async () => undefined };
64
+ },
65
+ },
66
+ }) as never;
67
+
68
+ await nativeApp.dev("windows", { upstream: "http://localhost:8282", lang: "en" });
69
+
70
+ expect(seen).toEqual([{ outDir: nativeApp.devOutDir("windows"), leftover: false }]);
71
+ expect(path.relative(nativeApp.outDir("windows"), nativeApp.devOutDir("windows"))).toStartWith("..");
42
72
  });
43
73
 
44
74
  test("a dev boot that came up says so in one line: platform, device, dev server, time", () => {
@@ -1,4 +1,3 @@
1
- import { mkdir } from "node:fs/promises";
2
1
  import path from "node:path";
3
2
  import type { AkanNativeConfig } from "@akanjs/native/config";
4
3
  import type { MobileEnv } from "akanjs";
@@ -70,6 +69,11 @@ export class NativeApp {
70
69
  return path.join(this.targetRoot, "native", platform);
71
70
  }
72
71
 
72
+ //? Apart from the builds: a desktop build empties its folder first, which would take a release and its installer.
73
+ devOutDir(platform: NativePlatform) {
74
+ return path.join(this.targetRoot, "dev", platform);
75
+ }
76
+
73
77
  /** The web root, from the production build the mobile commands run first. */
74
78
  async assembleWeb() {
75
79
  const dist = this.app.dist.cwdPath;
@@ -123,12 +127,16 @@ export class NativeApp {
123
127
  this.config({ platform, ...(server ? { server } : {}) }),
124
128
  ]);
125
129
  for (const warning of warnings) this.app.logger.warn(warning);
130
+ this.#assertValid(api, config);
131
+ return { api, config };
132
+ }
133
+
134
+ #assertValid(api: NativeBuildApiModule, config: AkanNativeConfig) {
126
135
  const problems = api.validateConfig(config, { appDir: this.app.cwdPath });
127
136
  if (problems.length)
128
137
  throw new Error(
129
138
  `Mobile target '${this.target.name}' makes an invalid native config:\n- ${problems.join("\n- ")}`,
130
139
  );
131
- return { api, config };
132
140
  }
133
141
 
134
142
  //? The app's own output: its console (mirrored by a dev build), the simulator log stream or logcat.
@@ -139,12 +147,12 @@ export class NativeApp {
139
147
  };
140
148
 
141
149
  //* `AKAN_PUBLIC_*` is already inlined into the CSR bundle, so the runtime reads no .env file of its own.
142
- #task(platform: NativePlatform, config: AkanNativeConfig): TaskOptions {
150
+ #task(platform: NativePlatform, config: AkanNativeConfig, outDir = this.outDir(platform)): TaskOptions {
143
151
  return {
144
152
  appDir: this.app.cwdPath,
145
153
  config,
146
154
  platform,
147
- outDir: this.outDir(platform),
155
+ outDir,
148
156
  envFiles: false,
149
157
  skipWebBuild: true,
150
158
  log: (event) => {
@@ -192,10 +200,11 @@ export class NativeApp {
192
200
  const boot: NativeDevBoot = { steps: [], lines: [] };
193
201
  this.#boot = boot;
194
202
  try {
195
- await mkdir(this.web.dir, { recursive: true });
203
+ //? The runtime bundles an index.html it finds here as the dev build's page, and a release build leaves one.
204
+ await this.web.clear();
196
205
  const [{ api, config }, { api: routes }] = await Promise.all([this.prepare(platform), this.app.getConfig()]);
197
206
  const session = await api.dev({
198
- ...this.#task(platform, config),
207
+ ...this.#task(platform, config, this.devOutDir(platform)),
199
208
  upstream,
200
209
  hmrPath: "/_akan/hmr",
201
210
  //? A dev page calls its own origin (akanjs baseEnv), so the gateway relays the socket it opens for the API too.
@@ -253,10 +262,7 @@ export class NativeApp {
253
262
  { channel, server }: { channel?: string; server?: DesktopServerBundle } = {},
254
263
  ) {
255
264
  NativeApp.#assertServerPlatform(platform, server);
256
- if (!this.target.config.updates)
257
- throw new Error(
258
- `Mobile target '${this.target.name}' has no updates: { url, publicKey } in akan.config.ts; \`akan update-keygen ${this.app.name}\` prints the key.`,
259
- );
265
+ this.#assertUpdates();
260
266
  await this.assembleWeb();
261
267
  const { api, config } = await this.prepare(platform, server);
262
268
  return await api.publishUpdate({
@@ -267,6 +273,46 @@ export class NativeApp {
267
273
  });
268
274
  }
269
275
 
276
+ /**
277
+ * What publishing refuses once the release is built: no updates settings, an invalid config, no signing key or
278
+ * another app's, and a desktop release whose server presence is not its channel's previous release's.
279
+ */
280
+ async assertPublishable(
281
+ platform: NativePlatform,
282
+ { channel, server = false }: { channel?: string; server?: boolean } = {},
283
+ ) {
284
+ this.#assertUpdates();
285
+ const [api, { config }] = await Promise.all([
286
+ NativeApi.load(this.app.cwdPath),
287
+ this.config({ platform, stageBin: false }),
288
+ ]);
289
+ this.#assertValid(api, config);
290
+ try {
291
+ api.checkPublishUpdate({
292
+ appDir: this.app.cwdPath,
293
+ config,
294
+ platform,
295
+ out: this.updatesDir,
296
+ server,
297
+ ...(channel ? { channel } : {}),
298
+ });
299
+ } catch (error) {
300
+ throw new Error(
301
+ `Mobile target '${this.target.name}': ${error instanceof Error ? error.message : String(error)}`,
302
+ {
303
+ cause: error,
304
+ },
305
+ );
306
+ }
307
+ }
308
+
309
+ #assertUpdates() {
310
+ if (!this.target.config.updates)
311
+ throw new Error(
312
+ `Mobile target '${this.target.name}' has no updates: { url, publicKey } in akan.config.ts; \`akan update-keygen ${this.app.name}\` prints the key.`,
313
+ );
314
+ }
315
+
270
316
  /** The key update releases of this target's app id on `platform` are signed with: made once, then read. */
271
317
  async updateKeygen(platform: NativePlatform) {
272
318
  const [api, { config }] = await Promise.all([
@@ -34,12 +34,16 @@ export class NativeWebDir {
34
34
  }
35
35
  static readonly #targetTag = "<script>window.__AKAN_MOBILE_TARGET__=";
36
36
 
37
+ async clear() {
38
+ await rm(this.dir, { recursive: true, force: true });
39
+ await mkdir(this.dir, { recursive: true });
40
+ }
41
+
37
42
  /** Rebuilds the folder from scratch and answers its files as web paths: relative, `/`-separated and sorted. */
38
43
  async assemble(target: AkanMobileTargetConfig, { html, publicDir, fontsDir }: NativeWebDirSources) {
39
44
  if (!(await Bun.file(html).exists()))
40
45
  throw new Error(`CSR html for mobile target '${target.name}' not found: ${html}`);
41
- await rm(this.dir, { recursive: true, force: true });
42
- await mkdir(this.dir, { recursive: true });
46
+ await this.clear();
43
47
  if (publicDir && (await NativeWebDir.#isDir(publicDir))) await cp(publicDir, this.dir, { recursive: true });
44
48
  if (fontsDir && (await NativeWebDir.#isDir(fontsDir)))
45
49
  await cp(fontsDir, path.join(this.dir, "_akan", "fonts"), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-beta.28",
3
+ "version": "3.0.0-beta.29",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -47,7 +47,7 @@
47
47
  "@earendil-works/pi-tui": "0.86.1",
48
48
  "@inquirer/prompts": "^8.4.3",
49
49
  "@tailwindcss/node": "^4.3.0",
50
- "akanjs": "3.0.0-beta.28",
50
+ "akanjs": "3.0.0-beta.29",
51
51
  "chalk": "^5.6.2",
52
52
  "commander": "^14.0.3",
53
53
  "dayjs": "^1.11.20",
@@ -76,6 +76,9 @@ describe("createServerEnvPlugin", () => {
76
76
  expect(proc.stderr.toString()).toContain(
77
77
  "env/env.server.develop.ts is not in this build: `akan build` bundles the server env of AKAN_PUBLIC_ENV=main alone",
78
78
  );
79
+ expect(proc.stderr.toString()).toContain(
80
+ "To run develop, build for it: AKAN_PUBLIC_ENV=develop in the workspace's .env, which outranks the shell, or --env develop on a desktop or mobile build.",
81
+ );
79
82
  });
80
83
 
81
84
  test("a custom branch is its own environment", async () => {
@@ -26,7 +26,7 @@ export const createServerEnvPlugin = ({ envDir, environment, environments }: Ser
26
26
  build.onLoad({ filter }, async (args) => {
27
27
  const name = path.basename(args.path, ".ts").slice("env.server.".length);
28
28
  const { exports } = new Bun.Transpiler({ loader: "ts" }).scan(await Bun.file(args.path).text());
29
- const message = `env/env.server.${name}.ts is not in this build: \`akan build\` bundles the server env of AKAN_PUBLIC_ENV=${environment} alone, the environment its image runs. Build with AKAN_PUBLIC_ENV=${name} to run ${name}.`;
29
+ const message = `env/env.server.${name}.ts is not in this build: \`akan build\` bundles the server env of AKAN_PUBLIC_ENV=${environment} alone, the environment its image runs. To run ${name}, build for it: AKAN_PUBLIC_ENV=${name} in the workspace's .env, which outranks the shell, or --env ${name} on a desktop or mobile build.`;
30
30
  return {
31
31
  loader: "js",
32
32
  contents: [