@akanjs/devkit 3.1.1-rc.17 → 3.1.1-rc.19

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/akanContext.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { capitalize, isRecord } from "akanjs/common";
3
+ import { capitalize, isRecord, parseRouteModuleKey } from "akanjs/common";
4
4
  import { extractBlockVersion, readDevkitVersion } from "./agentsIndex";
5
5
  import { AppExecutor, LibExecutor, type SysExecutor, type WorkspaceExecutor } from "./executors";
6
6
  import { FileSys } from "./fileSys";
@@ -48,12 +48,25 @@ export interface AkanModuleContext {
48
48
  files: string[];
49
49
  }
50
50
 
51
+ export interface AkanRouteContext {
52
+ /** The URL pattern without the `/:lang` segment every route starts with. */
53
+ path: string;
54
+ file: string;
55
+ search: string[];
56
+ /** The page or a layout above it calls `.guard()`. */
57
+ guarded: boolean;
58
+ devOnly: boolean;
59
+ }
60
+
51
61
  export interface AkanSysContext {
52
62
  type: "app" | "lib";
53
63
  name: string;
54
64
  path: string;
55
65
  hasConfig: boolean;
56
66
  modules: AkanModuleContext[];
67
+ /** Listed for the app `--app` names only; reading them parses every route file. */
68
+ routes?: AkanRouteContext[];
69
+ routeError?: string;
57
70
  }
58
71
 
59
72
  export interface AkanPackageContext {
@@ -557,6 +570,34 @@ const getSysModules = async (
557
570
  return modules.sort((a, b) => `${a.sysName}:${a.path}`.localeCompare(`${b.sysName}:${b.path}`));
558
571
  };
559
572
 
573
+ const getAppRoutes = async (app: AppExecutor, workspaceRoot: string): Promise<AkanRouteContext[]> => {
574
+ const sources = await app.getRouteSources();
575
+ const guardedLayouts = new Set<string>();
576
+ const pages: { source: (typeof sources)[number]; parsed: ReturnType<typeof parseRouteModuleKey> }[] = [];
577
+ for (const source of sources) {
578
+ const parsed = parseRouteModuleKey(source.key);
579
+ if (parsed.kind === "layout" && source.info.chain?.guarded) guardedLayouts.add(parsed.routeSegments.join("/"));
580
+ else if (parsed.kind === "page") pages.push({ source, parsed });
581
+ }
582
+ return pages
583
+ .map(({ source, parsed }) => {
584
+ const segments = parsed.routeSegments;
585
+ //? The layouts that wrap a page, as the route seed index walks them: an `_index` sits inside its own folder.
586
+ const reach = parsed.leaf === "_index" ? segments.length : segments.length - 1;
587
+ const underGuard = Array.from({ length: reach + 1 }, (_, idx) => segments.slice(0, idx).join("/")).some(
588
+ (prefix) => guardedLayouts.has(prefix),
589
+ );
590
+ return {
591
+ path: parsed.pattern.replace(/^\/:lang/, "") || "/",
592
+ file: path.relative(workspaceRoot, source.absPath),
593
+ search: source.info.chain?.search ?? [],
594
+ guarded: !!source.info.chain?.guarded || underGuard,
595
+ devOnly: source.info.devOnly,
596
+ };
597
+ })
598
+ .sort((a, b) => a.path.localeCompare(b.path));
599
+ };
600
+
560
601
  const getSysContext = async (
561
602
  workspace: WorkspaceExecutor,
562
603
  type: "app" | "lib",
@@ -564,7 +605,7 @@ const getSysContext = async (
564
605
  options: AkanContextOptions,
565
606
  ): Promise<AkanSysContext> => {
566
607
  const sys = type === "app" ? AppExecutor.from(workspace, name) : LibExecutor.from(workspace, name);
567
- return {
608
+ const context: AkanSysContext = {
568
609
  type,
569
610
  name,
570
611
  path: `${type}s/${name}`,
@@ -574,6 +615,12 @@ const getSysContext = async (
574
615
  module: options.module,
575
616
  }),
576
617
  };
618
+ if (sys instanceof AppExecutor && options.app === name)
619
+ await getAppRoutes(sys, workspace.workspaceRoot).then(
620
+ (routes) => (context.routes = routes),
621
+ (error: unknown) => (context.routeError = error instanceof Error ? error.message : String(error)),
622
+ );
623
+ return context;
577
624
  };
578
625
 
579
626
  export class AkanContextAnalyzer {
@@ -986,6 +1033,12 @@ export class AkanContextAnalyzer {
986
1033
  ...context.pkgs.map((pkg) => `- ${pkg.name}${pkg.version ? ` (${pkg.version})` : ""}`),
987
1034
  );
988
1035
 
1036
+ for (const app of context.apps) {
1037
+ if (app.routeError) lines.push("", `## Routes: ${app.name}`, `- Not read: ${app.routeError}`);
1038
+ else if (app.routes)
1039
+ lines.push("", `## Routes: ${app.name}`, ...AkanContextAnalyzer.#routeLines(app.name, app.routes));
1040
+ }
1041
+
989
1042
  const modules = AkanContextAnalyzer.findModules(context, moduleName);
990
1043
  lines.push("", "## Modules");
991
1044
  for (const module of modules) {
@@ -1000,4 +1053,34 @@ export class AkanContextAnalyzer {
1000
1053
  lines.push("", "## Validation", ...context.validationCommands.map((command) => `- \`${command}\``));
1001
1054
  return `${lines.join("\n")}\n`;
1002
1055
  }
1056
+
1057
+ static readonly routeListLimit = 60;
1058
+
1059
+ //? Past the limit the biggest top folders fold into one line each: a docs section alone would fill the context.
1060
+ static #routeLines(appName: string, routes: AkanRouteContext[]) {
1061
+ const folderOf = (route: AkanRouteContext) => route.file.split("/").slice(0, 4).join("/");
1062
+ const groups = Map.groupBy(routes, folderOf);
1063
+ const folded = new Set<string>();
1064
+ let listed = routes.length;
1065
+ for (const [folder, members] of [...groups].sort((a, b) => b[1].length - a[1].length)) {
1066
+ if (listed <= AkanContextAnalyzer.routeListLimit) break;
1067
+ folded.add(folder);
1068
+ listed -= members.length;
1069
+ }
1070
+ const lines = [`Paths omit the \`/:lang\` prefix; \`akan preview ${appName} <path>\` opens one.`];
1071
+ for (const route of routes) {
1072
+ if (folded.has(folderOf(route))) continue;
1073
+ const search = route.search.length ? ` ?${route.search.join("&")}` : "";
1074
+ const marks = `${route.guarded ? " (guarded)" : ""}${route.devOnly ? " (dev only)" : ""}`;
1075
+ lines.push(`- ${route.path}${search}${marks} — ${route.file}`);
1076
+ }
1077
+ for (const folder of folded) {
1078
+ const members = groups.get(folder) ?? [];
1079
+ const examples = members.slice(0, 3).map((route) => route.path);
1080
+ lines.push(
1081
+ `- ${folder}/: ${members.length} routes folded (${examples.join(", ")}, …); \`--format json\` lists every route`,
1082
+ );
1083
+ }
1084
+ return lines;
1085
+ }
1003
1086
  }
@@ -1,5 +1,5 @@
1
+ import { PreviewView } from "../../preview/PreviewView";
1
2
  import { AkanEditScope } from "../tools/AkanEditScope";
2
- import { PreviewView } from "./PreviewView";
3
3
  import type { TurnFeedbackFinding, TurnFeedbackSource } from "./TurnFeedback";
4
4
 
5
5
  export interface PreviewFeedbackOptions {
@@ -35,7 +35,7 @@ export class PreviewFeedback implements TurnFeedbackSource {
35
35
  for (const route of this.#options.routes ?? ["/en/"]) {
36
36
  const url = new URL(route, this.#options.previewUrl).toString();
37
37
  const probe = await PreviewView.probe(url, { screenshot: !!this.#options.canSeeImages });
38
- const problems = [...probe.pageErrors, ...probe.consoleErrors, ...probe.domFindings];
38
+ const problems = [...probe.pageErrors, ...probe.consoleErrors, ...probe.consoleWarnings, ...probe.domFindings];
39
39
  if (!problems.length) continue;
40
40
  findings.push(`**${url}**\n${problems.map((line) => `- ${line}`).join("\n")}`);
41
41
  if (probe.screenshot) images.push({ data: probe.screenshot, mimeType: "image/png" });
@@ -1,5 +1,7 @@
1
1
  // Importing this barrel loads the engine (~122MiB resident); a host needing cheap pieces imports the owning module.
2
2
  // agent/consoleToStderr is deliberately absent: it takes over globalThis.console, so an RPC entry imports it first.
3
+
4
+ export * from "../preview";
3
5
  export * from "./agent/AkanCodeCompaction";
4
6
  export * from "./agent/AkanCodeServices";
5
7
  export * from "./agent/akanCodeModel";
@@ -17,9 +19,7 @@ export * from "./agent/CodeSessionFork";
17
19
  export * from "./agent/CodeSessionIndex";
18
20
  export * from "./agent/SubagentPool";
19
21
  export * from "./feedback/DevLogFeedback";
20
- export * from "./feedback/PreviewChrome";
21
22
  export * from "./feedback/PreviewFeedback";
22
- export * from "./feedback/PreviewView";
23
23
  export * from "./feedback/TurnFeedback";
24
24
  export * from "./feedback/VerifyFeedback";
25
25
  export * from "./tools/AkanCli";
@@ -1,10 +1,42 @@
1
1
  ---
2
2
  name: akan-debug
3
- description: "Debug a running Akan app: where the dev-server log is, how to read it, and how to tell a runtime failure from a build one."
3
+ description: "Check and debug a running Akan app: akan preview for what a page shows and throws, where the dev-server log is, how to read it, and how to tell a runtime failure from a build one."
4
4
  ---
5
5
 
6
6
  # Akan debug
7
7
 
8
+ ## See the change run: `akan preview`
9
+
10
+ `akan preview <app> [paths]` opens pages of the app's dev server in a headless browser and prints, per page,
11
+ the HTTP status, the title, a slice of the visible text, the uncaught errors and rejections the page reported,
12
+ the server errors logged meanwhile, console errors and warnings, and layout smells. It exits 1 when a page failed.
13
+
14
+ - It uses the dev server that answers for this app and checkout. With none, it starts one for the run and
15
+ stops it after; `--keep` leaves it up for the next preview. It never starts a second server beside a
16
+ running one.
17
+ - Paths are comma-separated and get the default locale when they lack one: `akan preview <app> /board,/board/new`.
18
+ `--changed` adds the pages your uncommitted page and layout edits reach. A dynamic route needs a concrete
19
+ path (`/memo/1`); `akan context --app <app>` lists every route with its params and search names.
20
+ - The viewport is a 390x844 phone; `--desktop` is 1280x800. `--csr` loads each page in the CSR boot the native
21
+ shells load. `--screenshot` saves a PNG per page under `local/apps/<app>/runtime/preview/`, for a model
22
+ that reads images. `--format json` gives the same report as data.
23
+
24
+ ### Which platforms that covers
25
+
26
+ - Every change: the server-rendered web page and, with `--csr`, the bundle the iOS, Android and desktop
27
+ shells run.
28
+ - A change to `native/`, `plugin/`, an `isNativeApp()` branch, `native` in `akan.config.ts` or an
29
+ `@akanjs/native` plugin also needs that shell: `akan start-ios`, `start-android` or `start-desktop` on top of
30
+ the running dev server. Errors thrown in the shell reach the same dev-server log as `ClientReport` lines
31
+ tagged with the platform and target. Capture the shell with `xcrun simctl io booted screenshot <file>.png` or
32
+ `adb exec-out screencap -p > <file>.png`. When you cannot run the shell, say so: "not checked on iOS".
33
+ - Real devices, memory, OTA updates and store builds are for whoever releases.
34
+
35
+ ### What goes in your answer
36
+
37
+ The verdict lines of the pages you changed (`ok /en/board 200 "Board" 1.4s`) and every finding you left
38
+ unfixed. "Not opened in a browser" is not an answer once `akan preview` can run.
39
+
8
40
  ## The log is a file, and that file is the whole session
9
41
 
10
42
  A supervised `akan start` writes **`local/apps/<app>/runtime/dev.log`** — no ANSI, nothing truncated, every
@@ -17,8 +49,10 @@ what survives in the scrollback is bordered and truncated to the pane.
17
49
  - `tail -n 200 local/apps/<app>/runtime/dev.log` after an edit, to see what the reload produced.
18
50
  - `akan logs <app>` tails a running server with `--level --grep --endpoint --trace --child --role --origin
19
51
  --since` and `--json`.
20
- - The dev server's URL is printed in that log, and the port is allocated rather than fixed — the log is the
21
- only source for it.
52
+ - The dev server's URL is printed in that log. A server `akan preview` started logs to
53
+ `local/apps/<app>/runtime/preview/server.log` instead.
54
+ - An uncaught error or unhandled rejection in a page reaches the server log as a `ClientReport` error line,
55
+ naming the page, the client (`ssr` or `csr`) and the native target.
22
56
 
23
57
  ## Which loop you are in
24
58
 
@@ -1,12 +1,12 @@
1
1
  import type { InlineExtension } from "@earendil-works/pi-coding-agent";
2
2
  import type { CodeAgentMcpStatus, CodeAgentProfile, CodeAgentQuestion, CodeAgentSubagent } from "akanjs/common";
3
3
  import type { Workspace } from "../../commandDecorators";
4
+ import { PreviewView } from "../../preview/PreviewView";
4
5
  import type { CodeAgentModelRef } from "../agent/akanCodeModel";
5
6
  import type { CodeMailbox } from "../agent/CodeMailbox";
6
7
  import { SubagentPool, type SubagentSpend } from "../agent/SubagentPool";
7
8
  import { DevLogFeedback } from "../feedback/DevLogFeedback";
8
9
  import { PreviewFeedback } from "../feedback/PreviewFeedback";
9
- import { PreviewView } from "../feedback/PreviewView";
10
10
  import { TurnFeedback, type TurnFeedbackSource } from "../feedback/TurnFeedback";
11
11
  import { VerifyFeedback } from "../feedback/VerifyFeedback";
12
12
  import { AkanToolPack } from "./AkanToolPack";
@@ -5,7 +5,6 @@ import { makeCliTempWorkspace, tempRoots, writeText } from "../../testHelpers";
5
5
  import { CodeAgent, type CodeAgentOptions } from "../agent/CodeAgent";
6
6
  import { SubagentPool } from "../agent/SubagentPool";
7
7
  import { DevLogFeedback } from "../feedback/DevLogFeedback";
8
- import { PreviewView } from "../feedback/PreviewView";
9
8
  import { TurnFeedback, type TurnFeedbackSource } from "../feedback/TurnFeedback";
10
9
  import { AkanCodePlugins } from "./AkanCodePlugins";
11
10
  import { AkanEditScope } from "./AkanEditScope";
@@ -225,33 +224,6 @@ describe("AkanEnvKeys", () => {
225
224
  });
226
225
  });
227
226
 
228
- describe("PreviewView", () => {
229
- test("reports whether this Bun build can drive a headless page at all", () => {
230
- expect(typeof PreviewView.available).toBe("boolean");
231
- });
232
-
233
- test.skipIf(!PreviewView.available)(
234
- "finds an empty body and a console error on a real page",
235
- async () => {
236
- const server = Bun.serve({
237
- port: 0,
238
- fetch: () =>
239
- new Response('<!doctype html><html><body></body><script>console.error("boom")</script></html>', {
240
- headers: { "content-type": "text/html" },
241
- }),
242
- });
243
- try {
244
- const probe = await PreviewView.probe(`http://localhost:${server.port}/`);
245
- expect(probe.pageErrors.join(" ")).toContain("rendered empty");
246
- expect(probe.consoleErrors.join(" ")).toContain("boom");
247
- } finally {
248
- server.stop(true);
249
- }
250
- },
251
- 30_000,
252
- );
253
- });
254
-
255
227
  describe("SubagentPool", () => {
256
228
  const workspace = { workspaceRoot: "/tmp/akan" } as never;
257
229
 
package/executors.ts CHANGED
@@ -49,6 +49,7 @@ import { JsonFormatter } from "./jsonFormatter";
49
49
  import { Linter } from "./linter";
50
50
  import { ManifestDependencies } from "./manifestDependencies";
51
51
  import { resolveRepoName } from "./repoIdentity";
52
+ import type { RouteSourceInfo } from "./routeSourceValidator";
52
53
  import { AppInfo, isAkanFrameworkDependency, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
53
54
  import { Spinner } from "./spinner";
54
55
  // Type-only: `getTypeChecker` loads it on demand to keep `typescript` out of the resident module graph.
@@ -66,6 +67,12 @@ export interface PageRoot {
66
67
  keyPrefix: string;
67
68
  }
68
69
 
70
+ export interface AppRouteSource {
71
+ key: string;
72
+ absPath: string;
73
+ info: RouteSourceInfo;
74
+ }
75
+
69
76
  export interface WrittenFileContent extends FileContent {
70
77
  written: boolean;
71
78
  }
@@ -1278,6 +1285,7 @@ export class AppExecutor extends SysExecutor {
1278
1285
  }
1279
1286
 
1280
1287
  #pageKeys: string[] | null = null;
1288
+ #routeSources: AppRouteSource[] = [];
1281
1289
  #excludeDevOnlyPages = false;
1282
1290
  async getPageKeys({ refresh }: { refresh?: boolean } = {}): Promise<string[]> {
1283
1291
  if (this.#pageKeys && !refresh) return this.#pageKeys;
@@ -1288,6 +1296,7 @@ export class AppExecutor extends SysExecutor {
1288
1296
  const devOnlyKeys = new Set<string>();
1289
1297
  const devOnlyDirs: string[] = [];
1290
1298
  const pageRoots = await this.getPageRoots();
1299
+ const routeSources: AppRouteSource[] = [];
1291
1300
  const libKeyPrefixes = pageRoots.map((root) => root.keyPrefix).filter(Boolean);
1292
1301
  for (const root of pageRoots) {
1293
1302
  if (!(await FileSys.dirExists(root.dir))) continue;
@@ -1333,6 +1342,7 @@ export class AppExecutor extends SysExecutor {
1333
1342
  rootLayout: isRootLayout,
1334
1343
  pattern: parsed.pattern,
1335
1344
  });
1345
+ routeSources.push({ key, absPath, info });
1336
1346
  if (info.devOnly) {
1337
1347
  devOnlyKeys.add(key);
1338
1348
  //* A dev-only layout takes its whole subtree, or its pages would ship without their chrome.
@@ -1343,9 +1353,15 @@ export class AppExecutor extends SysExecutor {
1343
1353
  }
1344
1354
  }
1345
1355
  pageKeys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1356
+ this.#routeSources = routeSources.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
1346
1357
  this.#pageKeys = this.#excludeDevOnlyPages ? this.#dropDevOnlyPages(pageKeys, devOnlyKeys, devOnlyDirs) : pageKeys;
1347
1358
  return this.#pageKeys;
1348
1359
  }
1360
+ /** Each page and layout file `getPageKeys` read, with what its chain declares; overrides files are not listed. */
1361
+ async getRouteSources({ refresh }: { refresh?: boolean } = {}): Promise<AppRouteSource[]> {
1362
+ await this.getPageKeys({ refresh });
1363
+ return this.#routeSources;
1364
+ }
1349
1365
  #dropDevOnlyPages(pageKeys: string[], devOnlyKeys: Set<string>, devOnlyDirs: string[]): string[] {
1350
1366
  if (!devOnlyKeys.size) return pageKeys;
1351
1367
  const isDevOnly = (key: string) => devOnlyKeys.has(key) || devOnlyDirs.some((dir) => key.startsWith(dir));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.1-rc.17",
3
+ "version": "3.1.1-rc.19",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -34,6 +34,7 @@
34
34
  "./incrementalBuilder": "./incrementalBuilder/index.ts",
35
35
  "./mobile": "./mobile/index.ts",
36
36
  "./pipeline": "./pipeline/index.ts",
37
+ "./preview": "./preview/index.ts",
37
38
  "./transforms": "./transforms/index.ts",
38
39
  "./workflow": "./workflow/index.ts",
39
40
  "./*.ts": "./*.ts",
@@ -48,7 +49,7 @@
48
49
  "@earendil-works/pi-tui": "1.3.0",
49
50
  "@inquirer/prompts": "^8.7.3",
50
51
  "@tailwindcss/node": "^4.3.3",
51
- "akanjs": "3.1.1-rc.17",
52
+ "akanjs": "3.1.1-rc.19",
52
53
  "chalk": "^6.0.1",
53
54
  "commander": "^15.0.0",
54
55
  "dayjs": "^1.11.23",
@@ -2,6 +2,8 @@ import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
4
 
5
+ export type PreviewEngine = "webkit" | "chrome";
6
+
5
7
  // Bun.WebView's `webkit` backend is the macOS system framework and throws on every other platform.
6
8
  export class PreviewChrome {
7
9
  /** Read by `Bun.WebView` itself; listed so the availability check agrees with what the launcher will do. */
@@ -30,7 +32,7 @@ export class PreviewChrome {
30
32
  "Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
31
33
  ];
32
34
 
33
- static get backend(): "webkit" | "chrome" {
35
+ static get backend(): PreviewEngine {
34
36
  const declared = process.env[PreviewChrome.backendEnvKey];
35
37
  if (declared === "webkit" || declared === "chrome") return declared;
36
38
  return process.platform === "darwin" ? "webkit" : "chrome";
@@ -53,11 +55,15 @@ export class PreviewChrome {
53
55
  }
54
56
 
55
57
  static get available() {
56
- return PreviewChrome.backend === "webkit" ? process.platform === "darwin" : !!PreviewChrome.executable();
58
+ return PreviewChrome.isAvailable();
59
+ }
60
+
61
+ static isAvailable(engine: PreviewEngine = PreviewChrome.backend) {
62
+ return engine === "webkit" ? process.platform === "darwin" : !!PreviewChrome.executable();
57
63
  }
58
64
 
59
- static backendOption() {
60
- if (PreviewChrome.backend === "webkit") return "webkit";
65
+ static backendOption(engine: PreviewEngine = PreviewChrome.backend) {
66
+ if (engine === "webkit") return "webkit";
61
67
  // Docker's default 64MB /dev/shm is below what Chromium's renderer allocates; the crash looks like a hung load.
62
68
  const argv = ["--disable-dev-shm-usage"];
63
69
  // Chromium refuses uid 0 with its sandbox on, and uid 0 is the ordinary uid in a container.
@@ -70,9 +76,9 @@ export class PreviewChrome {
70
76
  "apt-get update && apt-get install -y --no-install-recommends chromium fonts-liberation fonts-noto-color-emoji fonts-noto-cjk && rm -rf /var/lib/apt/lists/*",
71
77
  ];
72
78
 
73
- static unavailableReason() {
74
- if (PreviewChrome.available) return undefined;
75
- if (PreviewChrome.backend === "webkit")
79
+ static unavailableReason(engine: PreviewEngine = PreviewChrome.backend) {
80
+ if (PreviewChrome.isAvailable(engine)) return undefined;
81
+ if (engine === "webkit")
76
82
  return `The webkit WebView backend exists only on macOS. Set ${PreviewChrome.backendEnvKey}=chrome and install a browser.`;
77
83
  return [
78
84
  "The page preview needs a browser and this machine has none on PATH.",
@@ -0,0 +1,219 @@
1
+ import { PreviewChrome, type PreviewEngine } from "./PreviewChrome";
2
+
3
+ export interface PreviewProbeOptions {
4
+ screenshot?: boolean;
5
+ width?: number;
6
+ height?: number;
7
+ engine?: PreviewEngine;
8
+ /** Bounds the whole probe: a dev server that is recovering takes the connection and never answers. */
9
+ timeoutMs?: number;
10
+ /** Waited after the load, so the errors effects throw land before the page is read. */
11
+ settleMs?: number;
12
+ }
13
+
14
+ export interface PreviewProbeResult {
15
+ url: string;
16
+ finalUrl: string;
17
+ status: number | null;
18
+ title: string;
19
+ text: string;
20
+ consoleErrors: string[];
21
+ consoleWarnings: string[];
22
+ pageErrors: string[];
23
+ /** Cheap structural smells that read as "the page is broken" without anyone looking at it. */
24
+ domFindings: string[];
25
+ screenshot?: string;
26
+ }
27
+
28
+ interface WebViewLike {
29
+ navigate(url: string): Promise<void>;
30
+ evaluate(expression: string): Promise<unknown>;
31
+ screenshot(options: { encoding: "base64" }): Promise<string>;
32
+ close(): void | Promise<void>;
33
+ }
34
+
35
+ interface PageRead {
36
+ href: string;
37
+ title: string;
38
+ text: string;
39
+ status: number | null;
40
+ empty: boolean;
41
+ pageErrors: string[];
42
+ domFindings: string[];
43
+ }
44
+
45
+ // Bun.WebView rather than a downloaded browser: on macOS its webkit backend needs no Chromium at all.
46
+ // It is experimental, so its whole surface stays behind this adapter; a Bun minor that changes it breaks one file.
47
+ export class PreviewView {
48
+ static get available() {
49
+ return PreviewView.isAvailable();
50
+ }
51
+
52
+ static isAvailable(engine: PreviewEngine = PreviewChrome.backend) {
53
+ return typeof (Bun as unknown as { WebView?: unknown }).WebView === "function" && PreviewChrome.isAvailable(engine);
54
+ }
55
+
56
+ static unavailableReason(engine: PreviewEngine = PreviewChrome.backend) {
57
+ if (typeof (Bun as unknown as { WebView?: unknown }).WebView !== "function")
58
+ return "This Bun build has no WebView, so the page preview is off.";
59
+ return PreviewChrome.unavailableReason(engine);
60
+ }
61
+
62
+ static async probe(
63
+ url: string,
64
+ {
65
+ screenshot = false,
66
+ width = 1280,
67
+ height = 800,
68
+ engine = PreviewChrome.backend,
69
+ timeoutMs = 30_000,
70
+ settleMs = 0,
71
+ }: PreviewProbeOptions = {},
72
+ ): Promise<PreviewProbeResult> {
73
+ 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
+ const view = PreviewView.#open({ width, height, engine, onConsole: (line) => logs.push(line) });
77
+ try {
78
+ let page = await PreviewView.#load(view, url, settleMs, within);
79
+ //? Right after a save the first load can render nothing (`[rscClient] module not loaded`); the next one has it.
80
+ if (page.empty) {
81
+ logs.length = 0;
82
+ page = await PreviewView.#load(view, url, settleMs, within);
83
+ }
84
+ const result: PreviewProbeResult = {
85
+ url,
86
+ finalUrl: page.href,
87
+ status: page.status ?? (await PreviewView.#statusOf(page.href, deadline)),
88
+ title: page.title,
89
+ text: page.text,
90
+ consoleErrors: logs.filter((line) => line.startsWith("error")),
91
+ consoleWarnings: logs.filter((line) => line.startsWith("warn")),
92
+ pageErrors: page.pageErrors,
93
+ domFindings: page.domFindings,
94
+ };
95
+ if (screenshot) result.screenshot = await within(view.screenshot({ encoding: "base64" }), "screenshotting");
96
+ return result;
97
+ } finally {
98
+ await view.close();
99
+ }
100
+ }
101
+
102
+ static async #load(
103
+ view: WebViewLike,
104
+ url: string,
105
+ settleMs: number,
106
+ within: <T>(work: Promise<T>, what: string) => Promise<T>,
107
+ ): Promise<PageRead> {
108
+ await within(view.navigate(url), "loading");
109
+ if (settleMs > 0) await Bun.sleep(settleMs);
110
+ const read = (await within(view.evaluate(readPageExpression), "reading")) as Partial<PageRead> | null;
111
+ return {
112
+ href: typeof read?.href === "string" ? read.href : url,
113
+ title: typeof read?.title === "string" ? read.title : "",
114
+ text: typeof read?.text === "string" ? read.text : "",
115
+ status: typeof read?.status === "number" && read.status > 0 ? read.status : null,
116
+ empty: read?.empty === true,
117
+ pageErrors: PreviewView.#strings(read?.pageErrors),
118
+ domFindings: PreviewView.#strings(read?.domFindings),
119
+ };
120
+ }
121
+
122
+ //? webkit has no Navigation Timing `responseStatus`, so the status comes from asking the server again, unredirected.
123
+ static async #statusOf(href: string, deadline: number) {
124
+ const left = Math.max(1, deadline - Date.now());
125
+ const response = await fetch(href, { redirect: "manual", signal: AbortSignal.timeout(left) }).catch(() => null);
126
+ return response?.status ?? null;
127
+ }
128
+
129
+ static async #within<T>(work: Promise<T>, deadline: number, what: string): Promise<T> {
130
+ let timer: ReturnType<typeof setTimeout> | undefined;
131
+ 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
+ );
136
+ });
137
+ try {
138
+ return await Promise.race([work, expired]);
139
+ } finally {
140
+ clearTimeout(timer);
141
+ }
142
+ }
143
+
144
+ static #open({
145
+ width,
146
+ height,
147
+ engine,
148
+ onConsole,
149
+ }: {
150
+ width: number;
151
+ height: number;
152
+ engine: PreviewEngine;
153
+ onConsole: (line: string) => void;
154
+ }) {
155
+ const Ctor = (Bun as unknown as { WebView: new (options: unknown) => WebViewLike }).WebView;
156
+ return new Ctor({
157
+ width,
158
+ height,
159
+ dataStore: "ephemeral",
160
+ backend: PreviewChrome.backendOption(engine),
161
+ // The console option is a callback taking `(type, ...args)`, not an object of console methods.
162
+ console: (type: string, ...args: unknown[]) => onConsole(`${type} ${PreviewView.formatConsole(args)}`),
163
+ });
164
+ }
165
+
166
+ /** Substitutes `%s` as the console does, since React passes a warning's detail as arguments, and cuts it short. */
167
+ static formatConsole(args: unknown[]) {
168
+ const [first, ...rest] = args;
169
+ const head =
170
+ typeof first === "string"
171
+ ? first.replace(/%[sdifoOc]/g, (token) => {
172
+ if (!rest.length) return token;
173
+ const value = rest.shift();
174
+ return token === "%c" ? "" : String(value);
175
+ })
176
+ : String(first);
177
+ return [head, ...rest.map(String)]
178
+ .join(" ")
179
+ .replace(/\n(\s*\n)+/g, "\n")
180
+ .slice(0, 1_500);
181
+ }
182
+
183
+ static #strings(value: unknown) {
184
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
185
+ }
186
+ }
187
+
188
+ // evaluate() takes one expression (a statement list is a SyntaxError), hence the IIFE; one call in flight per view.
189
+ // `view.title` is only refreshed by a navigation, so the title is read from the document.
190
+ const readPageExpression = `(() => {
191
+ const pageErrors = [];
192
+ const domFindings = [];
193
+ const body = document.body;
194
+ const rendered = body ? Array.from(body.children).filter((el) => !["SCRIPT", "STYLE", "TEMPLATE", "LINK", "NOSCRIPT"].includes(el.tagName) && !el.classList.contains("__akan_hmr_overlay")) : [];
195
+ const empty = !rendered.length && !(body && body.innerText && body.innerText.trim());
196
+ if (empty) pageErrors.push("The page body rendered empty.");
197
+ if (self.__AKAN_HMR_SYSTEM_PAGE__) pageErrors.push("The server could not render this page and answered with its error page: " + (body ? body.innerText.trim().replace(/\\s+/g, " ").slice(0, 400) : ""));
198
+ const overlay = document.querySelector('.__akan_hmr_overlay[data-status="error"]');
199
+ if (overlay) pageErrors.push("The dev build failed: " + overlay.textContent.trim().slice(0, 400));
200
+ const all = Array.from(document.querySelectorAll("body *"));
201
+ const viewportWidth = document.documentElement.clientWidth;
202
+ const pageWidth = document.documentElement.scrollWidth;
203
+ if (pageWidth > viewportWidth + 8) {
204
+ const wide = all.find((el) => el.getBoundingClientRect().right > viewportWidth + 8);
205
+ domFindings.push("The page is " + pageWidth + "px wide in a " + viewportWidth + "px viewport" + (wide ? ", first wider element: " + (wide.tagName.toLowerCase() + "." + (typeof wide.className === "string" ? wide.className : "")).slice(0, 120) : "") + ".");
206
+ }
207
+ const invisibleText = all.filter((el) => {
208
+ if (!el.textContent || !el.textContent.trim()) return false;
209
+ const style = getComputedStyle(el);
210
+ return style.color === style.backgroundColor && style.backgroundColor !== "rgba(0, 0, 0, 0)";
211
+ });
212
+ if (invisibleText.length) domFindings.push(invisibleText.length + " element(s) render text in the same colour as their background.");
213
+ const unresolved = all.filter((el) => /(^|\\s)(bg|text|border)-(base-100|base-content|primary-content|error)(\\s|$)/.test(typeof el.className === "string" ? el.className : ""));
214
+ if (unresolved.length) domFindings.push(unresolved.length + " element(s) use a dropped daisyUI colour slot that renders no CSS.");
215
+ const navigation = performance.getEntriesByType("navigation")[0];
216
+ const status = navigation && typeof navigation.responseStatus === "number" ? navigation.responseStatus : null;
217
+ const text = body ? body.innerText.trim().replace(/\\s+/g, " ").slice(0, 600) : "";
218
+ return { href: location.href, title: document.title, text, status, empty, pageErrors, domFindings };
219
+ })()`;
@@ -0,0 +1,2 @@
1
+ export * from "./PreviewChrome";
2
+ export * from "./PreviewView";
@@ -0,0 +1,76 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { PreviewView } from "./PreviewView";
3
+
4
+ const serve = (html: string, status = 200) =>
5
+ Bun.serve({ port: 0, fetch: () => new Response(html, { status, headers: { "content-type": "text/html" } }) });
6
+
7
+ describe("PreviewView", () => {
8
+ test("reports whether this Bun build can drive a headless page at all", () => {
9
+ expect(typeof PreviewView.available).toBe("boolean");
10
+ });
11
+
12
+ test("formats a console call the way the console prints it", () => {
13
+ expect(PreviewView.formatConsole(["%cstyled%c %s of %d", "color:red", "", "two", 3, "extra"])).toBe(
14
+ "styled two of 3 extra",
15
+ );
16
+ expect(PreviewView.formatConsole(["a\n\n\n \nb"])).toBe("a\nb");
17
+ expect(PreviewView.formatConsole(["x".repeat(2_000)])).toHaveLength(1_500);
18
+ });
19
+
20
+ test.skipIf(!PreviewView.available)(
21
+ "finds an empty body and a console error on a real page",
22
+ async () => {
23
+ const server = serve('<!doctype html><html><body></body><script>console.error("boom")</script></html>');
24
+ try {
25
+ const probe = await PreviewView.probe(`http://localhost:${server.port}/`);
26
+ expect(probe.pageErrors.join(" ")).toContain("rendered empty");
27
+ expect(probe.consoleErrors.join(" ")).toContain("boom");
28
+ } finally {
29
+ server.stop(true);
30
+ }
31
+ },
32
+ 30_000,
33
+ );
34
+
35
+ test.skipIf(!PreviewView.available)(
36
+ "reads the status, the live title, a failed dev build and a page wider than the viewport",
37
+ async () => {
38
+ const server = serve(
39
+ [
40
+ "<!doctype html><html><head><title>Before</title></head><body>",
41
+ '<div style="width:900px">wide</div>',
42
+ '<div class="__akan_hmr_overlay" data-status="error">Build failed: page.tsx</div>',
43
+ '<script>document.title = "After";</script>',
44
+ "</body></html>",
45
+ ].join(""),
46
+ 404,
47
+ );
48
+ try {
49
+ const probe = await PreviewView.probe(`http://localhost:${server.port}/missing`, { width: 390, height: 844 });
50
+ expect(probe.status).toBe(404);
51
+ expect(probe.title).toBe("After");
52
+ expect(probe.text).toBe("wide Build failed: page.tsx");
53
+ expect(probe.pageErrors).toEqual(["The dev build failed: Build failed: page.tsx"]);
54
+ expect(probe.domFindings.join(" ")).toContain("first wider element: div");
55
+ } finally {
56
+ server.stop(true);
57
+ }
58
+ },
59
+ 30_000,
60
+ );
61
+
62
+ test.skipIf(!PreviewView.available)(
63
+ "gives up on a server that takes the connection and never answers",
64
+ async () => {
65
+ const server = Bun.serve({ port: 0, fetch: () => new Promise<Response>(() => undefined) });
66
+ try {
67
+ await expect(PreviewView.probe(`http://localhost:${server.port}/`, { timeoutMs: 1_500 })).rejects.toThrow(
68
+ "Timed out loading",
69
+ );
70
+ } finally {
71
+ server.stop(true);
72
+ }
73
+ },
74
+ 30_000,
75
+ );
76
+ });
@@ -43,12 +43,14 @@ describe("RouteSourceValidator route chains", () => {
43
43
  const chain = (source: string, kind: "page" | "layout", pattern: string) =>
44
44
  RouteSourceValidator.validateRouteSourceExports(source, `page/${kind}.tsx`, kind, { pattern });
45
45
 
46
- test("reads params, prompt, apps and devOnly off a page() chain without evaluating it", () => {
46
+ test("reads params, search names, prompt, apps, guard and devOnly off a page() chain without evaluating it", () => {
47
47
  const source = [
48
48
  'import { page } from "akanjs/client";',
49
49
  "export default page()",
50
50
  ' .param("projectId", ID, { desc: "The project." })',
51
51
  ' .search("status", String)',
52
+ " .search(dynamicName, String)",
53
+ " .guard(() => signedIn())",
52
54
  " .config({ transition: 'stack', devOnly: true })",
53
55
  ' .prompt("briefProject", "Brief one project.")',
54
56
  ' .app("showProject", "Show one project.", ({ projectId }) => null)',
@@ -57,7 +59,14 @@ describe("RouteSourceValidator route chains", () => {
57
59
  ].join("\n");
58
60
  expect(chain(source, "page", "/:lang/project/:projectId")).toEqual({
59
61
  devOnly: true,
60
- chain: { kind: "page", params: ["projectId"], prompt: "briefProject", apps: ["showProject", "showBoard"] },
62
+ chain: {
63
+ kind: "page",
64
+ params: ["projectId"],
65
+ prompt: "briefProject",
66
+ apps: ["showProject", "showBoard"],
67
+ search: ["status"],
68
+ guarded: true,
69
+ },
61
70
  });
62
71
  });
63
72
 
@@ -17,6 +17,10 @@ export interface RouteSourceChain {
17
17
  prompt?: string;
18
18
  /** The `.app()` names, each one MCP App the build gives a view of its own. */
19
19
  apps: string[];
20
+ /** The `.search()` names, in chain order; a name that is not a literal is left out. */
21
+ search: string[];
22
+ /** The chain calls `.guard()`, which gates this route and, on a layout, every route under it. */
23
+ guarded: boolean;
20
24
  }
21
25
 
22
26
  interface ChainStage {
@@ -104,6 +108,8 @@ export class RouteSourceValidator {
104
108
  params: chain.params,
105
109
  ...(chain.prompt ? { prompt: chain.prompt } : {}),
106
110
  apps: chain.apps,
111
+ search: chain.search,
112
+ guarded: chain.guarded,
107
113
  },
108
114
  };
109
115
  }
@@ -168,8 +174,10 @@ export class RouteSourceValidator {
168
174
  static #chainOf(kind: RouteSourceChain["kind"], stages: ChainStage[], filePath: string) {
169
175
  const params: string[] = [];
170
176
  const apps: string[] = [];
177
+ const search: string[] = [];
171
178
  let prompt: string | undefined;
172
179
  let devOnly = false;
180
+ let guarded = false;
173
181
  const literal = (stage: ChainStage) => {
174
182
  const first = stage.args[0];
175
183
  if (!first || !ts.isStringLiteralLike(first))
@@ -182,10 +190,14 @@ export class RouteSourceValidator {
182
190
  if (stage.name === "param") params.push(literal(stage));
183
191
  else if (stage.name === "prompt") prompt = literal(stage);
184
192
  else if (stage.name === "app") apps.push(literal(stage));
185
- else if (stage.name === "config")
193
+ else if (stage.name === "guard") guarded = true;
194
+ else if (stage.name === "search") {
195
+ const first = stage.args[0];
196
+ if (first && ts.isStringLiteralLike(first)) search.push(first.text);
197
+ } else if (stage.name === "config")
186
198
  devOnly = RouteSourceValidator.#devOnlyOf(RouteSourceValidator.#unwrapExpression(stage.args[0]), filePath);
187
199
  }
188
- return { kind, params, prompt, apps, devOnly };
200
+ return { kind, params, prompt, apps, search, guarded, devOnly };
189
201
  }
190
202
 
191
203
  /** The same rule the loader applies: a page names every `[x]` it sits under, a layout may name a subset. */
@@ -1,95 +0,0 @@
1
- import { PreviewChrome } from "./PreviewChrome";
2
-
3
- export interface PreviewProbeResult {
4
- url: string;
5
- title: string;
6
- consoleErrors: string[];
7
- pageErrors: string[];
8
- /** Cheap structural smells that read as "the page is broken" without anyone looking at it. */
9
- domFindings: string[];
10
- screenshot?: string;
11
- }
12
-
13
- interface WebViewLike {
14
- navigate(url: string): Promise<void>;
15
- evaluate(expression: string): Promise<unknown>;
16
- screenshot(options: { encoding: "base64" }): Promise<string>;
17
- close(): void | Promise<void>;
18
- readonly title: string;
19
- }
20
-
21
- // Bun.WebView rather than a downloaded browser: on macOS its webkit backend needs no Chromium at all.
22
- // It is experimental, so its whole surface stays behind this adapter; a Bun minor that changes it breaks one file.
23
- export class PreviewView {
24
- static get available() {
25
- return typeof (Bun as unknown as { WebView?: unknown }).WebView === "function" && PreviewChrome.available;
26
- }
27
-
28
- static unavailableReason() {
29
- if (typeof (Bun as unknown as { WebView?: unknown }).WebView !== "function")
30
- return "This Bun build has no WebView, so the page preview is off.";
31
- return PreviewChrome.unavailableReason();
32
- }
33
-
34
- static async probe(url: string, { screenshot = false, width = 1280, height = 800 } = {}) {
35
- const logs: string[] = [];
36
- const view = PreviewView.#open({ width, height, onConsole: (line) => logs.push(line) });
37
- try {
38
- await view.navigate(url);
39
- const result: PreviewProbeResult = {
40
- url,
41
- title: view.title,
42
- consoleErrors: logs.filter((line) => line.startsWith("error") || line.startsWith("warn")),
43
- pageErrors: PreviewView.#strings(await view.evaluate(pageErrorsExpression)),
44
- domFindings: PreviewView.#strings(await view.evaluate(domFindingsExpression)),
45
- };
46
- if (screenshot) result.screenshot = await view.screenshot({ encoding: "base64" });
47
- return result;
48
- } finally {
49
- await view.close();
50
- }
51
- }
52
-
53
- static #open({ width, height, onConsole }: { width: number; height: number; onConsole: (line: string) => void }) {
54
- const Ctor = (Bun as unknown as { WebView: new (options: unknown) => WebViewLike }).WebView;
55
- return new Ctor({
56
- width,
57
- height,
58
- dataStore: "ephemeral",
59
- backend: PreviewChrome.backendOption(),
60
- // The console option is a callback taking `(type, ...args)`, not an object of console methods.
61
- console: (type: string, ...args: unknown[]) => onConsole(`${type} ${args.map(String).join(" ")}`),
62
- });
63
- }
64
-
65
- static #strings(value: unknown) {
66
- return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
67
- }
68
- }
69
-
70
- // evaluate() takes one expression (a statement list is a SyntaxError), hence the IIFEs; one call in flight per view.
71
- const pageErrorsExpression = `(() => {
72
- const found = [];
73
- const root = document.getElementById("__next") || document.body;
74
- const rendered = root ? Array.from(root.children).filter((el) => !["SCRIPT", "STYLE", "TEMPLATE", "LINK", "NOSCRIPT"].includes(el.tagName)) : [];
75
- if (!rendered.length && !(root && root.innerText && root.innerText.trim())) found.push("The page body rendered empty.");
76
- const overlay = document.querySelector("[data-nextjs-dialog], .error-overlay, #akan-error");
77
- if (overlay) found.push("An error overlay is on the page: " + overlay.textContent.slice(0, 400));
78
- return found;
79
- })()`;
80
-
81
- const domFindingsExpression = `(() => {
82
- const found = [];
83
- const all = Array.from(document.querySelectorAll("body *"));
84
- const overflowing = all.filter((el) => el.scrollWidth > document.documentElement.clientWidth + 8);
85
- if (overflowing.length) found.push(overflowing.length + " element(s) overflow the viewport horizontally, first: " + (overflowing[0].tagName + "." + overflowing[0].className).slice(0, 120));
86
- const invisibleText = all.filter((el) => {
87
- if (!el.textContent || !el.textContent.trim()) return false;
88
- const style = getComputedStyle(el);
89
- return style.color === style.backgroundColor && style.backgroundColor !== "rgba(0, 0, 0, 0)";
90
- });
91
- if (invisibleText.length) found.push(invisibleText.length + " element(s) render text in the same colour as their background.");
92
- const unresolved = all.filter((el) => /(^|\\s)(bg|text|border)-(base-100|base-content|primary-content|error)(\\s|$)/.test(el.className || ""));
93
- if (unresolved.length) found.push(unresolved.length + " element(s) use a dropped daisyUI colour slot that renders no CSS.");
94
- return found;
95
- })()`;