@akanjs/devkit 3.2.0-rc.0 → 3.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -177,6 +177,10 @@
177
177
  - **The CSR build and its MCP App views** rewrite `akanjs/ui` imports to the components a file names, as the SSR route builds already did, so a page that imports `Link` no longer bundles every component.
178
178
  - **Animations use `@react-spring/web`** instead of the `react-spring` umbrella package, which also installed the native, three.js, konva and zdog renderers.
179
179
 
180
+ - 686e0b8: `akan preview` and the code agent's page preview bound a page only once the browser is up. Before, a process's first page shared its `timeoutMs` with starting the browser. On a cold machine where Chrome took longer than that to start, the first page failed as `Timed out loading …; the dev server may be rebuilding or stuck.`
181
+
182
+ Starting the browser now has its own bound of 120 s, `startTimeoutMs` on `PreviewView.probe`. Running out of it reads `Timed out starting the <engine> browser after 120 s.`, where the engine is `chrome` or `webkit`. A browser that misses its bound may still come up late, so later pages in that process try again, but each waits only its own `timeoutMs` for the browser. Once the browser answers, the full bound applies again. So a browser that never starts holds the first page for 120 s, where before it held it for `timeoutMs` (60 s for `akan preview`, 30 s for the code agent's check).
183
+
180
184
  - eefd9cb: `akan preview <app> /_akan/studio` opens Studio instead of a 404. Preview added the default locale to every path, and the server routes `/_akan/*` without one, so `/en/_akan/studio` matched no page. A path under `/_akan`, `/__csr`, `/__rsc`, `/.well-known` or the app's API prefix, a file the app's `public/` holds, and the server's own `/favicon.ico`, `/robots.txt`, `sitemap.xml` and `manifest.webmanifest` now open as written: no locale, and no CSR boot query under `--csr`. Every other path, including one that already starts with a locale or ends in a dotted segment such as `/release/1.2.3`, is a page and gets the default locale as before. The `akan-debug` skill and the `akan preview` reference say so.
181
185
  - 4fcf44f: A new `rename-key` workflow renames a slice key or a Filter query key everywhere it is spelled. That covers the key itself, every name derived from it (`initMemoInPublic`, `memoListInPublic`, `queryByName`) in the app and every lib, and its module dictionary entry. It is the same rename the editor extension's F2 runs, so agents get it through `plan_workflow` / `apply_workflow` and the CLI through `akan workflow plan rename-key --app <app> --module <module> --slice <key> --to <newKey>` (or `--query <key>`). The plan lists each file it will change. A use that could also be another model's is left alone, and `akan verify` then reports it.
182
186
  - 9a9e1fa: A server page from `akan build` now preloads its client modules from `<head>`, so a cold load stops waiting on chains of imports.
@@ -343,6 +347,7 @@
343
347
  - Updated dependencies [df32c46]
344
348
  - Updated dependencies [5f809e8]
345
349
  - Updated dependencies [206bb69]
350
+ - Updated dependencies [6291fec]
346
351
  - Updated dependencies [0c480c6]
347
352
  - Updated dependencies [d9eaacc]
348
353
  - akanjs@3.2.0
@@ -20,10 +20,20 @@ const publicBlobOf = (publicKey: string) => {
20
20
  return parsed.getPublicSSH();
21
21
  };
22
22
 
23
+ //? ssh2 1.17's keygen strips a leading zero byte off an ed25519 public key, so 1 pair in 256 cannot be read back.
24
+ const ed25519Pair = () => {
25
+ for (let tries = 1; ; tries++) {
26
+ const pair = utils.generateKeyPairSync("ed25519");
27
+ const parsed = utils.parseKey(pair.private);
28
+ if (!(parsed instanceof Error)) return pair;
29
+ if (tries === 8) throw parsed;
30
+ }
31
+ };
32
+
23
33
  const sshServers: { server: Server; clients: Set<Connection> }[] = [];
24
34
 
25
35
  const startSshServer = async () => {
26
- const hostKey = utils.generateKeyPairSync("ed25519");
36
+ const hostKey = ed25519Pair();
27
37
  const methods: string[] = [];
28
38
  const clients = new Set<Connection>();
29
39
  const server = new Server({ hostKeys: [hostKey.private] }, (client) => {
@@ -136,7 +146,7 @@ describe("createTunnel", () => {
136
146
  const ssh = await startSshServer();
137
147
  const knownHosts = path.join(root, "known_hosts.json");
138
148
  const keyPath = path.join(root, "id_tunnel");
139
- await writeFile(keyPath, utils.generateKeyPairSync("ed25519").private);
149
+ await writeFile(keyPath, ed25519Pair().private);
140
150
  Object.assign(process.env, {
141
151
  SSH_TUNNEL_PORT: String(ssh.port),
142
152
  SSH_TUNNEL_KNOWN_HOSTS: knownHosts,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.2.0-rc.0",
3
+ "version": "3.2.0-rc.1",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -41,7 +41,7 @@
41
41
  "./*": "./*.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@akanjs/language": "3.2.0-rc.0",
44
+ "@akanjs/language": "3.2.0-rc.1",
45
45
  "@earendil-works/pi-agent-core": "1.3.0",
46
46
  "@earendil-works/pi-ai": "1.3.0",
47
47
  "@earendil-works/pi-codemode": "1.3.0",
@@ -50,7 +50,7 @@
50
50
  "@earendil-works/pi-tui": "1.3.0",
51
51
  "@inquirer/prompts": "^8.7.3",
52
52
  "@tailwindcss/node": "^4.3.3",
53
- "akanjs": "3.2.0-rc.0",
53
+ "akanjs": "3.2.0-rc.1",
54
54
  "chalk": "^6.0.1",
55
55
  "commander": "^15.0.0",
56
56
  "dayjs": "^1.11.23",
@@ -12,12 +12,12 @@ export class PreviewChrome {
12
12
  /** Set this to drive Chrome on macOS too, e.g. to reproduce what a pod sees. */
13
13
  static readonly backendEnvKey = "AKAN_CODE_WEBVIEW_BACKEND";
14
14
 
15
- /** The executables Bun looks for on PATH before giving up. */
15
+ /** The executables Bun looks for on PATH before giving up, in its order: the first found is the one it starts. */
16
16
  static readonly executables = [
17
- "chromium",
18
- "chromium-browser",
19
17
  "google-chrome-stable",
20
18
  "google-chrome",
19
+ "chromium-browser",
20
+ "chromium",
21
21
  "brave-browser",
22
22
  "microsoft-edge",
23
23
  "chrome",
@@ -5,7 +5,8 @@ export interface PreviewProbeOptions {
5
5
  width?: number;
6
6
  height?: number;
7
7
  engine?: PreviewEngine;
8
- /** Bounds the whole probe: a dev server that is recovering takes the connection and never answers. */
8
+ startTimeoutMs?: number;
9
+ /** Bounds the probe once the browser is up: a dev server that is recovering takes the connection and never answers. */
9
10
  timeoutMs?: number;
10
11
  /** Waited after the load, so the errors effects throw land before the page is read. */
11
12
  settleMs?: number;
@@ -45,6 +46,10 @@ interface PageRead {
45
46
  // Bun.WebView rather than a downloaded browser: on macOS its webkit backend needs no Chromium at all.
46
47
  // It is experimental, so its whole surface stays behind this adapter; a Bun minor that changes it breaks one file.
47
48
  export class PreviewView {
49
+ /** A view's first navigation includes the browser's start, which took ~35 s on a cold CI runner. */
50
+ static readonly startTimeoutMs = 120_000;
51
+ static readonly #stalled = new Set<PreviewEngine>();
52
+
48
53
  static get available() {
49
54
  return PreviewView.isAvailable();
50
55
  }
@@ -66,15 +71,31 @@ export class PreviewView {
66
71
  width = 1280,
67
72
  height = 800,
68
73
  engine = PreviewChrome.backend,
74
+ startTimeoutMs = PreviewView.startTimeoutMs,
69
75
  timeoutMs = 30_000,
70
76
  settleMs = 0,
71
77
  }: PreviewProbeOptions = {},
72
78
  ): Promise<PreviewProbeResult> {
73
79
  const logs: string[] = [];
74
- const deadline = Date.now() + timeoutMs;
75
- const within = <T>(work: Promise<T>, what: string) => PreviewView.#within(work, deadline, `${what} ${url}`);
76
80
  const view = PreviewView.#open({ width, height, engine, onConsole: (line) => logs.push(line) });
77
81
  try {
82
+ //? A browser that missed its start bound may still come up, so a later page retries it within only its own bound.
83
+ const startMs = PreviewView.#stalled.has(engine) ? Math.min(startTimeoutMs, timeoutMs) : startTimeoutMs;
84
+ const stalled = new Error(`Timed out starting the ${engine} browser after ${startMs / 1_000} s.`);
85
+ await PreviewView.#within(view.navigate("about:blank"), Date.now() + startMs, stalled).then(
86
+ () => PreviewView.#stalled.delete(engine),
87
+ (error: unknown) => {
88
+ if (error === stalled) PreviewView.#stalled.add(engine);
89
+ throw error;
90
+ },
91
+ );
92
+ const deadline = Date.now() + timeoutMs;
93
+ const within = <T>(work: Promise<T>, what: string) =>
94
+ PreviewView.#within(
95
+ work,
96
+ deadline,
97
+ new Error(`Timed out ${what} ${url}; the dev server may be rebuilding or stuck.`),
98
+ );
78
99
  let page = await PreviewView.#load(view, url, settleMs, within);
79
100
  //? Right after a save the first load can render nothing (`[rscClient] module not loaded`); the next one has it.
80
101
  if (page.empty) {
@@ -126,13 +147,10 @@ export class PreviewView {
126
147
  return response?.status ?? null;
127
148
  }
128
149
 
129
- static async #within<T>(work: Promise<T>, deadline: number, what: string): Promise<T> {
150
+ static async #within<T>(work: Promise<T>, deadline: number, expiry: Error): Promise<T> {
130
151
  let timer: ReturnType<typeof setTimeout> | undefined;
131
152
  const expired = new Promise<never>((_, reject) => {
132
- timer = setTimeout(
133
- () => reject(new Error(`Timed out ${what}; the dev server may be rebuilding or stuck.`)),
134
- Math.max(0, deadline - Date.now()),
135
- );
153
+ timer = setTimeout(() => reject(expiry), Math.max(0, deadline - Date.now()));
136
154
  });
137
155
  try {
138
156
  return await Promise.race([work, expired]);
@@ -1,9 +1,42 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { PreviewChrome } from "./PreviewChrome";
2
6
  import { PreviewView } from "./PreviewView";
3
7
 
4
8
  const serve = (html: string, status = 200) =>
5
9
  Bun.serve({ port: 0, fetch: () => new Response(html, { status, headers: { "content-type": "text/html" } }) });
6
10
 
11
+ const browserTestMs = PreviewView.startTimeoutMs + 30_000;
12
+ const slowChromeRuns = PreviewView.isAvailable("chrome") && process.platform !== "win32";
13
+
14
+ const withSlowChrome = async (body: string, startSeconds = 3) => {
15
+ const dir = await mkdtemp(path.join(tmpdir(), "akan-slow-chrome-"));
16
+ const chrome = path.join(dir, "chrome");
17
+ await writeFile(chrome, `#!/bin/sh\nsleep ${startSeconds}\nexec "${PreviewChrome.executable()}" "$@"\n`);
18
+ await chmod(chrome, 0o755);
19
+ const script = `const { PreviewView } = await import(${JSON.stringify(path.join(import.meta.dir, "PreviewView.ts"))});\n${body}`;
20
+ //? Chrome is spawned once per process, so only a fresh process takes the slow executable.
21
+ const child = Bun.spawn([process.execPath, "-e", script], {
22
+ env: { ...process.env, [PreviewChrome.pathEnvKey]: chrome },
23
+ stdout: "pipe",
24
+ stderr: "pipe",
25
+ timeout: PreviewView.startTimeoutMs + 15_000,
26
+ });
27
+ try {
28
+ const [out, err, code] = await Promise.all([
29
+ new Response(child.stdout).text(),
30
+ new Response(child.stderr).text(),
31
+ child.exited,
32
+ ]);
33
+ return { code, out: out.trim(), err };
34
+ } finally {
35
+ child.kill();
36
+ await rm(dir, { recursive: true, force: true });
37
+ }
38
+ };
39
+
7
40
  describe("PreviewView", () => {
8
41
  test("reports whether this Bun build can drive a headless page at all", () => {
9
42
  expect(typeof PreviewView.available).toBe("boolean");
@@ -29,7 +62,7 @@ describe("PreviewView", () => {
29
62
  server.stop(true);
30
63
  }
31
64
  },
32
- 30_000,
65
+ browserTestMs,
33
66
  );
34
67
 
35
68
  test.skipIf(!PreviewView.available)(
@@ -56,7 +89,7 @@ describe("PreviewView", () => {
56
89
  server.stop(true);
57
90
  }
58
91
  },
59
- 30_000,
92
+ browserTestMs,
60
93
  );
61
94
 
62
95
  test.skipIf(!PreviewView.available)(
@@ -71,6 +104,57 @@ describe("PreviewView", () => {
71
104
  server.stop(true);
72
105
  }
73
106
  },
74
- 30_000,
107
+ browserTestMs,
108
+ );
109
+
110
+ test.skipIf(!slowChromeRuns)(
111
+ "starts the page's bound once the browser is up, however long Chrome takes to spawn",
112
+ async () => {
113
+ const server = serve("<!doctype html><title>Up</title><body>up</body>");
114
+ try {
115
+ const { code, out, err } = await withSlowChrome(
116
+ `const probe = await PreviewView.probe("http://localhost:${server.port}/", { engine: "chrome", timeoutMs: 1_500 });
117
+ console.log(probe.text);`,
118
+ );
119
+ expect(out || err).toBe("up");
120
+ expect(code).toBe(0);
121
+ } finally {
122
+ server.stop(true);
123
+ }
124
+ },
125
+ browserTestMs,
126
+ );
127
+
128
+ test.skipIf(!slowChromeRuns)(
129
+ "gives a later page only its own bound for a browser that missed its start, and recovers once it is up",
130
+ async () => {
131
+ const { code, out, err } = await withSlowChrome(
132
+ `const open = () =>
133
+ PreviewView.probe("about:blank", { engine: "chrome", startTimeoutMs: 1_500, timeoutMs: 1_000 }).then(
134
+ () => "opened",
135
+ (error) => error.message,
136
+ );
137
+ console.log(await open());
138
+ console.log(await open());
139
+ let late = await open();
140
+ for (let tries = 0; late !== "opened" && tries < 100; tries++) late = await open();
141
+ console.log(late);
142
+ Bun.WebView.closeAll();
143
+ await Bun.sleep(500);
144
+ console.log(await open());
145
+ Bun.WebView.closeAll();`,
146
+ 6,
147
+ );
148
+ expect(out || err).toBe(
149
+ [
150
+ "Timed out starting the chrome browser after 1.5 s.",
151
+ "Timed out starting the chrome browser after 1 s.",
152
+ "opened",
153
+ "Timed out starting the chrome browser after 1.5 s.",
154
+ ].join("\n"),
155
+ );
156
+ expect(code).toBe(0);
157
+ },
158
+ browserTestMs,
75
159
  );
76
160
  });