@akanjs/devkit 3.0.0-beta.25 → 3.0.0-beta.27

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.
Files changed (34) hide show
  1. package/DEV_RUNTIME_KNOBS.md +9 -6
  2. package/akanApp/akanApp.host.ts +79 -22
  3. package/akanApp/devHostPolicy.test.ts +56 -11
  4. package/akanApp/devHostPolicy.ts +10 -10
  5. package/csrE2e/csrE2eHarness.fixture.ts +55 -5
  6. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  7. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  8. package/frontendBuild/clientEntryDiscovery.test.ts +75 -0
  9. package/frontendBuild/clientEntryDiscovery.ts +112 -70
  10. package/frontendBuild/csrDevArtifactWriter.test.ts +8 -0
  11. package/frontendBuild/csrDevArtifactWriter.ts +4 -0
  12. package/frontendBuild/csrDevBundler.ts +1 -1
  13. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  14. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  15. package/frontendBuild/csrDevModuleCompiler.test.ts +261 -0
  16. package/frontendBuild/csrDevModuleCompiler.ts +95 -20
  17. package/frontendBuild/csrDevPatcher.test.ts +234 -2
  18. package/frontendBuild/csrDevPatcher.ts +184 -10
  19. package/frontendBuild/csrDevPaths.ts +32 -11
  20. package/frontendBuild/csrDevResolver.test.ts +76 -2
  21. package/frontendBuild/csrDevResolver.ts +141 -12
  22. package/frontendBuild/csrDevTypes.ts +2 -0
  23. package/frontendBuild/devRegistryBundler.ts +18 -3
  24. package/frontendBuild/ssrDevBundler.ts +2 -8
  25. package/incrementalBuilder/incrementalBuilder.host.test.ts +34 -0
  26. package/incrementalBuilder/incrementalBuilder.host.ts +18 -3
  27. package/incrementalBuilder/incrementalBuilder.proc.test.ts +62 -1
  28. package/incrementalBuilder/incrementalBuilder.proc.ts +58 -24
  29. package/integration/devStability.integration.test.ts +9 -2
  30. package/mobile/nativeApp.test.ts +15 -0
  31. package/mobile/nativeApp.ts +54 -14
  32. package/mobile/nativeAppLine.test.ts +62 -0
  33. package/mobile/nativeAppLine.ts +63 -0
  34. package/package.json +2 -2
@@ -165,7 +165,7 @@ class IncrementalBuilder {
165
165
  this.#logger.verbose(`build-route ok routeId=${msg.routeId} newEntries=${delta.newEntries.length}`);
166
166
  //? The builder's generation, not the route cache's: the cache's does not move when a fixed file maps to no entry,
167
167
  //? so the rebuild's ok would carry the failure's own number.
168
- this.#sendBuildStatus("route", { generation: this.#generation, ok: true, files: msg.seeds });
168
+ this.#sendBuildStatus("route", { generation: this.#generation, ok: true, files: msg.seeds, scope: msg.routeId });
169
169
  return {
170
170
  type: "build-route-res",
171
171
  id: msg.id,
@@ -185,24 +185,30 @@ class IncrementalBuilder {
185
185
  } catch (err) {
186
186
  const errMsg = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
187
187
  this.#logger.error(`build-route failed routeId=${msg.routeId}: ${errMsg}`);
188
- this.#sendBuildStatus("route", { generation: this.#generation, ok: false, files: msg.seeds, message: errMsg });
188
+ this.#sendBuildStatus("route", {
189
+ generation: this.#generation,
190
+ ok: false,
191
+ files: msg.seeds,
192
+ message: errMsg,
193
+ scope: msg.routeId,
194
+ });
189
195
  return { type: "build-route-res", id: msg.id, ok: false, error: errMsg };
190
196
  }
191
197
  }
192
198
  #sendBuildStatus(
193
199
  phase: BuildPhase,
194
- { generation, ok, files, message }: { generation?: number; ok: boolean; files?: string[]; message?: string },
200
+ {
201
+ generation,
202
+ ok,
203
+ files,
204
+ message,
205
+ scope,
206
+ }: { generation?: number; ok: boolean; files?: string[]; message?: string; scope?: string },
195
207
  ): void {
196
208
  if (typeof generation !== "number") return;
197
209
  BuilderChannel.emit({
198
210
  type: "build-status",
199
- data: {
200
- generation,
201
- phase,
202
- ok,
203
- files: files ?? [],
204
- message,
205
- },
211
+ data: { generation, phase, ok, files: files ?? [], message, ...(scope !== undefined ? { scope } : {}) },
206
212
  });
207
213
  }
208
214
  async #enqueueFast<T>(label: string, fn: () => Promise<T>): Promise<T> {
@@ -445,11 +451,8 @@ class IncrementalBuilder {
445
451
  if (ssrToWorker) needs.unshift("ssr");
446
452
  const batch: BatchJob = { generation, needs, changedFiles: files, trace, ...(discovery ? { discovery } : {}) };
447
453
  // A worker's registry build rewrites the directory its patcher reads, so the next save waits for it.
448
- if (needs.includes("csr") || needs.includes("ssr")) {
449
- await this.#workQueue.enqueueBatch(batch);
450
- if (needs.includes("csr")) this.#patcher?.forget();
451
- if (needs.includes("ssr")) this.#ssrPatcher?.forget();
452
- } else void this.#workQueue.enqueueBatch(batch).catch(this.#slowLaneFailed("batch"));
454
+ if (needs.includes("csr") || needs.includes("ssr")) await this.#workQueue.enqueueBatch(batch);
455
+ else void this.#workQueue.enqueueBatch(batch).catch(this.#slowLaneFailed("batch"));
453
456
  return;
454
457
  }
455
458
  if (discovery)
@@ -525,6 +528,9 @@ class IncrementalBuilder {
525
528
  const hold = await ServerGraphFile.touches(await ServerGraphFile.read(this.#artifactDir), files, (file) =>
526
529
  ServerGraphFile.clientExportsOf(file),
527
530
  );
531
+ //? Ahead of this save's own discovery job in the slow lane: which client entries a page still names decides which
532
+ //? pending roots this patch keeps.
533
+ this.#discovery.invalidate?.(files);
528
534
  return await this.#withSsrLock(
529
535
  async () => await this.#runSsrPatcher(patcher, files, { trace, hold, batchGeneration: generation }),
530
536
  );
@@ -532,14 +538,16 @@ class IncrementalBuilder {
532
538
 
533
539
  //* Adds the entries a route build names to the registry, since the tab requires them by id. A registry that cannot
534
540
  //* take them fails no route build: the page answers, and its tab waits for the registry while the overlay says why.
535
- async #ensureSsrEntries(entries: string[]): Promise<void> {
541
+ async #ensureSsrEntries(entries: string[], { crashRetry = false }: { crashRetry?: boolean } = {}): Promise<void> {
536
542
  if (entries.length === 0) return;
537
543
  //? The boot build takes every entry the routes reach, so the first page answers now and its tab waits for the
538
544
  //? registry (boot.json) instead; whatever that build missed is added right after it lands.
539
545
  const arming = this.#ssrArming;
540
546
  if (arming) {
541
547
  void this.#runBeside(
542
- arming.then(async () => await this.#ensureSsrEntries(entries)).catch(this.#slowLaneFailed("ssr-ensure")),
548
+ arming
549
+ .then(async () => await this.#ensureSsrEntries(entries, { crashRetry }))
550
+ .catch(this.#slowLaneFailed("ssr-ensure")),
543
551
  );
544
552
  return;
545
553
  }
@@ -558,6 +566,8 @@ class IncrementalBuilder {
558
566
  //? the SSR lock that save's patch waits on.
559
567
  if (this.#ssrBroken) return;
560
568
  if (this.#ssrCrashedAt !== null && Date.now() - this.#ssrCrashedAt < IncrementalBuilder.#crashRetryMs) {
569
+ //? Handed over just now with nothing run for them: the retry must find them to hand over again.
570
+ patcher?.releaseHandedOver();
561
571
  this.#retryAfterCrash(this.#ssrCrashedAt, entries);
562
572
  return;
563
573
  }
@@ -566,6 +576,9 @@ class IncrementalBuilder {
566
576
  `ssr-registry could not take a route's entries; the save that fixes it rebuilds it: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
567
577
  );
568
578
  });
579
+ //? A worker that died took these entries with it and left them pending nowhere: they are tried once more after the
580
+ //? crash window, but a retry that crashes again waits for the next route build or save.
581
+ if (this.#ssrCrashedAt !== null && !crashRetry) this.#retryAfterCrash(this.#ssrCrashedAt, entries);
569
582
  });
570
583
  }
571
584
 
@@ -580,7 +593,9 @@ class IncrementalBuilder {
580
593
  const retried = [...this.#crashRetryEntries];
581
594
  this.#crashRetryEntries.clear();
582
595
  if (this.shuttingDown || retried.length === 0) return;
583
- void this.#runBeside(this.#ensureSsrEntries(retried)).catch(this.#slowLaneFailed("ssr-crash-retry"));
596
+ void this.#runBeside(this.#ensureSsrEntries(retried, { crashRetry: true })).catch(
597
+ this.#slowLaneFailed("ssr-crash-retry"),
598
+ );
584
599
  },
585
600
  Math.max(0, crashedAt + IncrementalBuilder.#crashRetryMs - Date.now()),
586
601
  );
@@ -654,8 +669,12 @@ class IncrementalBuilder {
654
669
 
655
670
  // Runs the worker directly, under the SSR lock its callers hold: queueing it could wait on a batch that waits on them.
656
671
  async #runSsrWorker(generation: number): Promise<void> {
657
- const result = await this.#runBatch({ generation, needs: ["ssr"], changedFiles: [] });
658
- this.#ssrPatcher?.forget();
672
+ let result: BuildBatchResult;
673
+ try {
674
+ result = await this.#runBatch({ generation, needs: ["ssr"], changedFiles: [] });
675
+ } finally {
676
+ this.#ssrPatcher?.forget();
677
+ }
659
678
  this.#noteSsrWorker(result);
660
679
  if (result.errors.ssr) throw new Error(result.errors.ssr);
661
680
  }
@@ -741,9 +760,20 @@ class IncrementalBuilder {
741
760
  //? The slow lane runs in order, so every batch up to this one has had its discovery job.
742
761
  this.#discoveredThrough = Math.max(this.#discoveredThrough, work.generation);
743
762
  // A worker writing ssr-dev must not overlap the boot build, which no longer runs in this lane.
744
- if (work.needs.includes("ssr"))
745
- await this.#withSsrLock(async () => this.#noteSsrWorker(await this.#runBatch(work)));
746
- else await this.#runBatch(work);
763
+ //? Forgotten inside the lock: a route build's check waiting on it would patch the old state over the worker's.
764
+ try {
765
+ if (work.needs.includes("ssr"))
766
+ await this.#withSsrLock(async () => {
767
+ try {
768
+ this.#noteSsrWorker(await this.#runBatch(work));
769
+ } finally {
770
+ this.#ssrPatcher?.forget();
771
+ }
772
+ });
773
+ else await this.#runBatch(work);
774
+ } finally {
775
+ if (work.needs.includes("csr")) this.#patcher?.forget();
776
+ }
747
777
  }
748
778
 
749
779
  async #refreshDiscovery({ files, refresh, generation }: DiscoveryJob): Promise<void> {
@@ -762,8 +792,12 @@ class IncrementalBuilder {
762
792
  if (result.cssAssets) this.#artifact = { ...this.#artifact, cssAssets: result.cssAssets };
763
793
  // A need the worker died before reporting goes red here instead of looking silently successful.
764
794
  if (result.crashed) {
795
+ const crashedNeeds = result.crashedNeeds ?? needs;
796
+ //? A worker that died wrote no failure for the roots handed to it: they must not wait on one that never comes.
797
+ if (crashedNeeds.includes("csr")) this.#patcher?.releaseHandedOver();
798
+ if (crashedNeeds.includes("ssr")) this.#ssrPatcher?.releaseHandedOver();
765
799
  // `base` is not a `BuildPhase` and never comes through here: `#buildBootDeps` throws into the degraded boot.
766
- for (const need of result.crashedNeeds ?? needs)
800
+ for (const need of crashedNeeds)
767
801
  if (need !== "base")
768
802
  this.#sendBuildStatus(need, {
769
803
  generation,
@@ -589,6 +589,7 @@ describe("dev resource budgets", () => {
589
589
  // A ceiling the replacement is back over within the interval is reported, and still enforced (recycles are
590
590
  // throttled), never disabled for the session. Whether it is back over depends on the machine's allocator, so the
591
591
  // warning is asserted only when it is; the decision itself is unit-tested (devHostPolicy).
592
+ const settledFrom = host.markLog();
592
593
  for (let i = 1; i <= 3; i++) {
593
594
  const { mark } = await harness.editUntilSeen(host, (attempt) =>
594
595
  harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-settled-${i}-${attempt}`),
@@ -596,8 +597,14 @@ describe("dev resource budgets", () => {
596
597
  await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS).catch(() => undefined);
597
598
  }
598
599
  const settled = await DevStabilityHarness.builderProcess(host.proc.pid);
599
- if ((settled?.rssBytes ?? 0) >= 200 * MB)
600
- await host.waitForLogSince(start, /ceiling costs about one boot build per interval/, WAIT_MS);
600
+ const warned = /ceiling costs about one boot build per interval/.test(host.logs.join("").slice(start));
601
+ //? Past the interval (a slow machine) the next report recycles instead of warning: either answer settles it.
602
+ if ((settled?.rssBytes ?? 0) >= 200 * MB && !warned)
603
+ await host.waitForLogSince(
604
+ settledFrom,
605
+ /ceiling costs about one boot build per interval|recycling builder pid=\d+|skipped: the builder fell to/,
606
+ WAIT_MS,
607
+ );
601
608
  expect(host.logs.join("").slice(start)).not.toMatch(/no longer enforcing it this session/);
602
609
  });
603
610
 
@@ -38,6 +38,21 @@ describe("NativeApp", () => {
38
38
  expect(admin.outDir("android")).toBe(path.join(appDir, ".akan/mobile/admin/native/android"));
39
39
  });
40
40
 
41
+ test("a dev boot that came up says so in one line: platform, device, dev server, time", () => {
42
+ const app = { ...fakeApp(), name: "portal" } as unknown as App;
43
+ const startedAt = performance.now() - 12_400;
44
+ expect(new NativeApp(app, target()).readyLine("macos", { upstream: "http://localhost:8283", startedAt })).toMatch(
45
+ /^portal desktop ready · http:\/\/localhost:8283 · 12\.\ds$/,
46
+ );
47
+ expect(
48
+ new NativeApp(app, target({ name: "admin" })).readyLine("ios", {
49
+ upstream: "http://localhost:8283",
50
+ device: { name: "iPhone 16" },
51
+ startedAt,
52
+ }),
53
+ ).toMatch(/^portal\/admin ios ready · iPhone 16 · http:\/\/localhost:8283 · 12\.\ds$/);
54
+ });
55
+
41
56
  test("opens a dev build on its target's home, the CSR shell under the locale", () => {
42
57
  expect(new NativeApp(fakeApp(), target({ indexPath: "/explore" })).startPath("en")).toBe(
43
58
  "/en/explore?csr=true&akanMobileTarget=default&akanMobileIndexPath=%2Fexplore",
@@ -2,6 +2,7 @@ import { mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type { AkanNativeConfig } from "@akanjs/native/config";
4
4
  import type { App } from "../commandDecorators";
5
+ import { Executor } from "../executors";
5
6
  import {
6
7
  type DesktopPlatform,
7
8
  type MobilePlatform,
@@ -10,6 +11,7 @@ import {
10
11
  targetHtmlFilename,
11
12
  } from "./mobileTarget";
12
13
  import { NativeApi, type NativeBuildApiModule } from "./nativeApi";
14
+ import { NativeAppLine, type NativeAppLineRead } from "./nativeAppLine";
13
15
  import { NativeConfig } from "./nativeConfig";
14
16
  import { NativeWebDir } from "./nativeWebDir";
15
17
 
@@ -24,6 +26,11 @@ export interface NativeRunOptions {
24
26
  teamId?: string;
25
27
  }
26
28
 
29
+ interface NativeDevBoot {
30
+ steps: string[];
31
+ lines: NativeAppLineRead[];
32
+ }
33
+
27
34
  export interface NativeDevOptions extends NativeRunOptions {
28
35
  /** The akan dev server the gateway proxies. */
29
36
  upstream: string;
@@ -35,6 +42,8 @@ export interface NativeDevOptions extends NativeRunOptions {
35
42
  export class NativeApp {
36
43
  readonly targetRoot: string;
37
44
  readonly web: NativeWebDir;
45
+ //? A dev boot holds its steps and the app's first lines back: one line says it is up, all of it when it failed.
46
+ #boot: NativeDevBoot | null = null;
38
47
 
39
48
  constructor(
40
49
  readonly app: App,
@@ -83,7 +92,9 @@ export class NativeApp {
83
92
 
84
93
  //? The app's own output: its console (mirrored by a dev build), the simulator log stream or logcat.
85
94
  #appLine = (line: string) => {
86
- this.app.logger.info(`[${this.target.name}] ${line}`);
95
+ const read = NativeAppLine.read(line, { verbose: Executor.verbose });
96
+ if (this.#boot) this.#boot.lines.push(read);
97
+ else this.app.logger[read.level](read.message);
87
98
  };
88
99
 
89
100
  //* `AKAN_PUBLIC_*` is already inlined into the CSR bundle, so the runtime reads no .env file of its own.
@@ -98,6 +109,7 @@ export class NativeApp {
98
109
  log: (event) => {
99
110
  if (event.level === "error") this.app.logger.error(event.message);
100
111
  else if (event.level === "warn") this.app.logger.warn(event.message);
112
+ else if (this.#boot) this.#boot.steps.push(event.message);
101
113
  else if (event.level === "tool") this.app.logger.verbose(event.message);
102
114
  else this.app.logger.info(event.message);
103
115
  },
@@ -128,19 +140,47 @@ export class NativeApp {
128
140
  //* The gateway serves the page and its HMR socket on the app origin; API calls go to the dev server itself (baseEnv),
129
141
  //* which an Android device reaches through the reversed port.
130
142
  async dev(platform: NativePlatform, { upstream, lang, device, teamId }: NativeDevOptions) {
131
- await mkdir(this.web.dir, { recursive: true });
132
- const [{ api, config }, { api: routes }] = await Promise.all([this.prepare(), this.app.getConfig()]);
133
- return await api.dev({
134
- ...this.#task(platform, config),
135
- upstream,
136
- hmrPath: "/_akan/hmr",
137
- //? A dev page calls its own origin (akanjs baseEnv), so the gateway relays the socket it opens for the API too.
138
- wsPaths: [`${routes.prefix}${routes.websocketPrefix}`],
139
- onLine: this.#appLine,
140
- startPath: this.startPath(lang),
141
- ...(device ? { device } : {}),
142
- ...(teamId ? { ios: { signing: { teamId } } } : {}),
143
- });
143
+ const startedAt = performance.now();
144
+ const boot: NativeDevBoot = { steps: [], lines: [] };
145
+ this.#boot = boot;
146
+ try {
147
+ await mkdir(this.web.dir, { recursive: true });
148
+ const [{ api, config }, { api: routes }] = await Promise.all([this.prepare(), this.app.getConfig()]);
149
+ const session = await api.dev({
150
+ ...this.#task(platform, config),
151
+ upstream,
152
+ hmrPath: "/_akan/hmr",
153
+ //? A dev page calls its own origin (akanjs baseEnv), so the gateway relays the socket it opens for the API too.
154
+ wsPaths: [`${routes.prefix}${routes.websocketPrefix}`],
155
+ onLine: this.#appLine,
156
+ startPath: this.startPath(lang),
157
+ ...(device ? { device } : {}),
158
+ ...(teamId ? { ios: { signing: { teamId } } } : {}),
159
+ });
160
+ this.#boot = null;
161
+ this.app.logger.info(this.readyLine(platform, { upstream, device: session.device, startedAt }));
162
+ for (const step of [...boot.steps, `pages through ${session.gateway}`]) this.app.logger.debug(step);
163
+ for (const line of boot.lines) this.app.logger[line.level](line.message);
164
+ return session;
165
+ } catch (error) {
166
+ this.#boot = null;
167
+ for (const step of boot.steps) this.app.logger.info(step);
168
+ for (const line of boot.lines) this.app.logger[line.level === "debug" ? "info" : line.level](line.message);
169
+ throw error;
170
+ }
171
+ }
172
+
173
+ /** What a dev boot that came up says, and all it says: `cmdc ios ready · iPhone 16 · http://localhost:8283 · 12.4s`. */
174
+ readyLine(
175
+ platform: NativePlatform,
176
+ { upstream, device, startedAt }: { upstream: string; device?: { name: string }; startedAt: number },
177
+ ) {
178
+ const label = platform === "ios" || platform === "android" ? platform : "desktop";
179
+ const target = this.target.name === "default" ? "" : `/${this.target.name}`;
180
+ const seconds = ((performance.now() - startedAt) / 1000).toFixed(1);
181
+ return [`${this.app.name}${target} ${label} ready`, ...(device ? [device.name] : []), upstream, `${seconds}s`].join(
182
+ " · ",
183
+ );
144
184
  }
145
185
 
146
186
  async releaseIos({ teamId, adHoc = false }: { teamId?: string; adHoc?: boolean } = {}) {
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { NativeAppLine } from "./nativeAppLine";
3
+
4
+ describe("NativeAppLine", () => {
5
+ test("a page logger line keeps its level and its logger's name", () => {
6
+ expect(NativeAppLine.read("[page warn] [WsClient] WebSocket message process failed")).toEqual({
7
+ level: "warn",
8
+ message: "[page:WsClient] WebSocket message process failed",
9
+ });
10
+ });
11
+
12
+ test("a bare console call keeps its level, `log` reading as info", () => {
13
+ expect(NativeAppLine.read("[page log] hello")).toEqual({ level: "info", message: "[page] hello" });
14
+ expect(NativeAppLine.read("[page debug] [akan:frame:x:1] csr.render {}")).toEqual({
15
+ level: "debug",
16
+ message: "[page:akan:frame:x:1] csr.render {}",
17
+ });
18
+ });
19
+
20
+ test("a continuation line follows its message's level without a prefix of its own", () => {
21
+ expect(NativeAppLine.read("[page+ error] at run (app.js:1:2)")).toEqual({
22
+ level: "error",
23
+ message: " at run (app.js:1:2)",
24
+ });
25
+ });
26
+
27
+ test("a second window is named, the first is not", () => {
28
+ expect(NativeAppLine.read("[page#2 info] [App] ready")).toEqual({ level: "info", message: "[page#2:App] ready" });
29
+ });
30
+
31
+ test("logcat lines read by tag and priority", () => {
32
+ expect(NativeAppLine.read("W/AkanNativeConsole( 1234): [WsClient] failed")).toEqual({
33
+ level: "warn",
34
+ message: "[page:WsClient] failed",
35
+ });
36
+ expect(NativeAppLine.read("E/AndroidRuntime( 1234): FATAL EXCEPTION: main")).toEqual({
37
+ level: "error",
38
+ message: "[AndroidRuntime] FATAL EXCEPTION: main",
39
+ });
40
+ expect(
41
+ NativeAppLine.read("I/AkanNative( 1234): pages from http://10.0.2.2:5000 (akan-native dev --hmr)").level,
42
+ ).toBe("debug");
43
+ });
44
+
45
+ test("the host's own lines stay visible but for where its pages come from", () => {
46
+ expect(NativeAppLine.read("[akan-native] opened in the browser: https://x.test")).toEqual({
47
+ level: "info",
48
+ message: "[akan-native] opened in the browser: https://x.test",
49
+ });
50
+ expect(
51
+ NativeAppLine.read("[akan-native native] pages from http://127.0.0.1:6000 (akan-native dev --hmr)").level,
52
+ ).toBe("debug");
53
+ });
54
+
55
+ test("a line no page or host wrote is kept for debug, or for info when verbose", () => {
56
+ expect(NativeAppLine.read("WebContent[123] Could not signal service")).toEqual({
57
+ level: "debug",
58
+ message: "WebContent[123] Could not signal service",
59
+ });
60
+ expect(NativeAppLine.read("WebContent[123] Could not signal service", { verbose: true }).level).toBe("info");
61
+ });
62
+ });
@@ -0,0 +1,63 @@
1
+ import type { LogLevel } from "akanjs/common";
2
+
3
+ export interface NativeAppLineRead {
4
+ level: LogLevel;
5
+ message: string;
6
+ }
7
+
8
+ //* A line a native dev build prints — the page console its host mirrors, the simulator's console, logcat — read back
9
+ //* at the level the page logged it, so the terminal shows it once, stamped by the app's own logger.
10
+ export class NativeAppLine {
11
+ //? The host's grammar (desktop host.ts, iOS AkanNativeShell.swift): `[page<+><#window> <level>] text`, where `+`
12
+ //? marks a continuation line of a multi-line message and `#n` any window but the first.
13
+ static readonly #page = /^\[page(\+)?(?:#(\d+))? (trace|verbose|debug|log|info|warn|error)\] ?(.*)$/;
14
+ static readonly #logcat = /^([VDIWEFA])\/([^(]+?)\s*\(\s*\d+\):\s?(.*)$/;
15
+ static readonly #named = /^\[([^\]\s]+)\] ([\s\S]*)$/;
16
+ static readonly #logcatLevels: { [priority: string]: LogLevel } = {
17
+ V: "verbose",
18
+ D: "debug",
19
+ I: "info",
20
+ W: "warn",
21
+ E: "error",
22
+ F: "error",
23
+ A: "error",
24
+ };
25
+
26
+ /** `verbose` keeps the lines no page or host wrote (WebKit, the simulator) at info instead of debug. */
27
+ static read(line: string, { verbose = false }: { verbose?: boolean } = {}): NativeAppLineRead {
28
+ const logcat = NativeAppLine.#logcat.exec(line);
29
+ if (logcat) {
30
+ const [, priority = "I", tag = "", text = ""] = logcat;
31
+ const level = NativeAppLine.#logcatLevels[priority] ?? "info";
32
+ if (tag === "AkanNativeConsole") return NativeAppLine.#pageLine(level, text, { continuation: false });
33
+ if (tag === "AkanNative") return NativeAppLine.#hostLine(`[akan-native] ${text}`, level);
34
+ return { level, message: `[${tag}] ${text}` };
35
+ }
36
+ const page = NativeAppLine.#page.exec(line);
37
+ if (page) {
38
+ const [, continuation, window, level = "info", text = ""] = page;
39
+ return NativeAppLine.#pageLine(level === "log" ? "info" : (level as LogLevel), text, {
40
+ continuation: continuation === "+",
41
+ window,
42
+ });
43
+ }
44
+ if (line.startsWith("[akan-native")) return NativeAppLine.#hostLine(line, "info");
45
+ return { level: verbose ? "info" : "debug", message: line };
46
+ }
47
+
48
+ static #pageLine(
49
+ level: LogLevel,
50
+ text: string,
51
+ { continuation, window }: { continuation: boolean; window?: string },
52
+ ): NativeAppLineRead {
53
+ if (continuation) return { level, message: ` ${text}` };
54
+ const named = NativeAppLine.#named.exec(text);
55
+ const scope = `page${window ? `#${window}` : ""}${named ? `:${named[1]}` : ""}`;
56
+ return { level, message: `[${scope}] ${named ? named[2] : text}` };
57
+ }
58
+
59
+ //? The host's own lines are about the host, not the app: where its pages come from is boot detail.
60
+ static #hostLine(line: string, level: LogLevel): NativeAppLineRead {
61
+ return { level: /\bpages from\b/.test(line) ? "debug" : level, message: line };
62
+ }
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-beta.25",
3
+ "version": "3.0.0-beta.27",
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.25",
50
+ "akanjs": "3.0.0-beta.27",
51
51
  "chalk": "^5.6.2",
52
52
  "commander": "^14.0.3",
53
53
  "dayjs": "^1.11.20",