@akanjs/devkit 3.2.0-rc.1 → 3.2.0-rc.3
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 +52 -1
- package/akanContext.ts +1 -3
- package/akanMcpContract.ts +5 -4
- package/artifact/storeBootstrap.ts +3 -2
- package/codeAgent/skills/akan-scaffold/SKILL.md +2 -1
- package/commandDecorators/command.ts +35 -18
- package/commandDecorators/commandDecorators.test.ts +75 -3
- package/commandDecorators/targetMeta.ts +2 -0
- package/constantFieldNames.test.ts +34 -0
- package/constantFieldNames.ts +18 -0
- package/frontendBuild/buildRouteClient.test.ts +43 -0
- package/frontendBuild/clientEntryDiscovery.test.ts +52 -0
- package/frontendBuild/clientEntryDiscovery.ts +28 -3
- package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
- package/frontendBuild/csrArtifactBuilder.ts +9 -3
- package/frontendBuild/csrDevBundler.ts +12 -2
- package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
- package/frontendBuild/csrDevModuleCompiler.ts +37 -8
- package/frontendBuild/csrDevPatcher.test.ts +89 -2
- package/frontendBuild/csrDevPatcher.ts +47 -7
- package/frontendBuild/csrDevTypes.ts +16 -0
- package/frontendBuild/cssCompiler.test.ts +63 -1
- package/frontendBuild/devBarrelImports.ts +132 -0
- package/frontendBuild/devRegistryBundler.ts +19 -4
- package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
- package/frontendBuild/ssrDevBundler.ts +13 -2
- package/package.json +3 -3
- package/preview/PreviewView.ts +2 -0
- package/preview/previewView.test.ts +46 -9
- package/transforms/barrelAnalyzer.ts +46 -1
- package/transforms/barrelImportsPlugin.ts +61 -45
- package/transforms/transforms.test.ts +67 -1
- package/workflow/artifacts.ts +17 -0
- package/workflow/index.ts +1 -0
- package/workflow/plainName.ts +15 -0
- package/workflow/plan.ts +55 -21
- package/workspaceGraph.test.ts +200 -3
- package/workspaceGraph.ts +46 -8
|
@@ -4,7 +4,7 @@ import { appGenerationOf, CSR_DEV_MANIFEST_FILE, type CsrDevManifest } from "aka
|
|
|
4
4
|
import type { CsrDevCompileResult } from "./csrDevModuleCompiler";
|
|
5
5
|
import { CsrDevPaths } from "./csrDevPaths";
|
|
6
6
|
import { CsrDevResolver } from "./csrDevResolver";
|
|
7
|
-
import type { CsrDevCode, CsrDevContext, CsrDevGraph } from "./csrDevTypes";
|
|
7
|
+
import type { CsrDevBarrelStamp, CsrDevCode, CsrDevContext, CsrDevGraph } from "./csrDevTypes";
|
|
8
8
|
import type { CsrDevUpdate, CsrDevUpdateOptions, DevRegistryBundler } from "./devRegistryBundler";
|
|
9
9
|
|
|
10
10
|
interface CsrDevPatcherState {
|
|
@@ -109,6 +109,7 @@ export class CsrDevPatcher {
|
|
|
109
109
|
const present = CsrDevPatcher.#presence(changedFiles);
|
|
110
110
|
const forgotten = this.#forgetDeletedModules(state, present);
|
|
111
111
|
const changed = onlyRoots ? new Set<string>() : await this.#changedModules(graph, changedFiles, present);
|
|
112
|
+
const restamped = onlyRoots ? null : await this.#renarrowed(context, graph, changedFiles, changed);
|
|
112
113
|
for (const id of this.#withoutFactory(state, present)) changed.add(id);
|
|
113
114
|
for (const file of entries.changed) changed.add(paths.idOf(file));
|
|
114
115
|
const rootFiles = [...Object.values(entries.files), ...roots.filter((file) => fs.existsSync(file))];
|
|
@@ -121,6 +122,8 @@ export class CsrDevPatcher {
|
|
|
121
122
|
this.#bundler.reloadsOnEntryChange &&
|
|
122
123
|
(entries.changed.length > 0 || JSON.stringify(entryIds) !== JSON.stringify(graph.entries));
|
|
123
124
|
if (changed.size === 0) {
|
|
125
|
+
//? No importer moved (none compiles), so the restamped barrels already hold.
|
|
126
|
+
if (restamped) graph.barrels = { ...graph.barrels, ...restamped };
|
|
124
127
|
if (carried.length === 0) return { kind: "unchanged" };
|
|
125
128
|
return await this.#retryCarried(context, state, carried, { announce, allowWholeAppBuilds });
|
|
126
129
|
}
|
|
@@ -188,15 +191,11 @@ export class CsrDevPatcher {
|
|
|
188
191
|
forgotten.push(id);
|
|
189
192
|
}
|
|
190
193
|
graph.pending = graph.pending.filter((id) => !orphans.includes(id));
|
|
191
|
-
this.#bundler.merge(graph, resolver, compiled);
|
|
194
|
+
this.#bundler.merge(graph, resolver, compiled, { ...restamped, ...result.barrels });
|
|
192
195
|
if (!this.#resident) delete graph.failed;
|
|
193
196
|
for (const module of compiled) this.#delegated.delete(module.id);
|
|
194
197
|
graph.entries = entryIds;
|
|
195
|
-
|
|
196
|
-
if (module.vendor || !code) continue;
|
|
197
|
-
code.modules.set(module.id, module.factory);
|
|
198
|
-
if (module.helpers) code.helpers.set(module.helpers.hash, module.helpers.definition);
|
|
199
|
-
}
|
|
198
|
+
if (code) CsrDevPatcher.#remember(code, compiled);
|
|
200
199
|
const changedIds = compiled.filter((module) => !module.vendor).map((module) => module.id);
|
|
201
200
|
const constantId = changedIds.find((id) => id.endsWith(".constant.ts"));
|
|
202
201
|
// A model class swapped under live store state would mix old and new instances; reload instead.
|
|
@@ -359,6 +358,7 @@ export class CsrDevPatcher {
|
|
|
359
358
|
modules: [...result.modules, ...more.modules],
|
|
360
359
|
refusedVendors: [...result.refusedVendors, ...more.refusedVendors],
|
|
361
360
|
unresolved: [...result.unresolved, ...more.unresolved],
|
|
361
|
+
barrels: { ...result.barrels, ...more.barrels },
|
|
362
362
|
};
|
|
363
363
|
}
|
|
364
364
|
|
|
@@ -508,6 +508,46 @@ export class CsrDevPatcher {
|
|
|
508
508
|
return changed;
|
|
509
509
|
}
|
|
510
510
|
|
|
511
|
+
//? The new stamps go into the graph with the compile that follows (`merge`), never ahead of it: a round that fails or
|
|
512
|
+
//? is handed back must leave the barrel stale for the next one.
|
|
513
|
+
async #renarrowed(
|
|
514
|
+
context: CsrDevContext,
|
|
515
|
+
graph: CsrDevGraph,
|
|
516
|
+
changedFiles: string[],
|
|
517
|
+
changed: Set<string>,
|
|
518
|
+
): Promise<Record<string, CsrDevBarrelStamp>> {
|
|
519
|
+
const { paths } = this.#bundler;
|
|
520
|
+
const saved = new Set(changedFiles.map((file) => paths.idOf(file)));
|
|
521
|
+
const stale = Object.entries(graph.barrels ?? {})
|
|
522
|
+
.filter(([, { read }]) =>
|
|
523
|
+
Object.entries(read).some(
|
|
524
|
+
([id, mtimeMs]) => saved.has(id) || CsrDevPaths.mtimeOf(paths.fileOf(id)) !== mtimeMs,
|
|
525
|
+
),
|
|
526
|
+
)
|
|
527
|
+
.map(([barrel]) => barrel);
|
|
528
|
+
const barrelImports = stale.length > 0 ? await this.#bundler.barrelImports(context) : null;
|
|
529
|
+
if (!barrelImports) return {};
|
|
530
|
+
const barrels = await barrelImports.stamps(stale, (file) => paths.idOf(file));
|
|
531
|
+
const moved = stale.filter((barrel) => barrels[barrel]?.digest !== graph.barrels?.[barrel]?.digest);
|
|
532
|
+
for (const [id, module] of Object.entries(graph.modules)) {
|
|
533
|
+
for (const barrel of moved) {
|
|
534
|
+
const narrowed = module.narrowed?.[barrel];
|
|
535
|
+
if (!narrowed || (await barrelImports.fingerprint(barrel, narrowed.names)) === narrowed.hash) continue;
|
|
536
|
+
changed.add(id);
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return barrels;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
static #remember(code: CsrDevCode, compiled: CsrDevCompileResult["modules"]) {
|
|
544
|
+
for (const module of compiled) {
|
|
545
|
+
if (module.vendor) continue;
|
|
546
|
+
code.modules.set(module.id, module.factory);
|
|
547
|
+
if (module.helpers) code.helpers.set(module.helpers.hash, module.helpers.definition);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
511
551
|
//? A new file can take over a relative import another module already resolved (`Foo.tsx` created beside
|
|
512
552
|
//? `Foo/index.tsx`), with no change to that module's own text: it compiles again, and resolves against the disk.
|
|
513
553
|
#shadowedImporters(graph: CsrDevGraph, changedFiles: string[]): string[] {
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import type { PageEntry } from "../artifact/implicitRootLayout";
|
|
2
2
|
|
|
3
|
+
export interface CsrDevNarrowedImport {
|
|
4
|
+
names: string[];
|
|
5
|
+
hash: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface CsrDevBarrelStamp {
|
|
9
|
+
/** The mtime of each file and directory the barrel's analysis read, by id. */
|
|
10
|
+
read: Record<string, number>;
|
|
11
|
+
/** Each export name with the file it resolves to, hashed: what every importer's narrowing reads. */
|
|
12
|
+
digest: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
3
15
|
export interface CsrDevGraphModule {
|
|
4
16
|
vendor: boolean;
|
|
5
17
|
mtimeMs: number;
|
|
6
18
|
hash: string;
|
|
7
19
|
deps: string[];
|
|
8
20
|
helpers?: string;
|
|
21
|
+
narrowed?: Record<string, CsrDevNarrowedImport>;
|
|
9
22
|
}
|
|
10
23
|
|
|
11
24
|
export interface CsrDevGraph {
|
|
@@ -22,6 +35,7 @@ export interface CsrDevGraph {
|
|
|
22
35
|
failed?: { roots: string[]; files: Record<string, string> };
|
|
23
36
|
/** The stamp of the signal and dictionary sources the last whole build inlined; see `RegistryMetadataFingerprint`. */
|
|
24
37
|
metadata?: string;
|
|
38
|
+
barrels?: Record<string, CsrDevBarrelStamp>;
|
|
25
39
|
}
|
|
26
40
|
|
|
27
41
|
export interface CsrDevContext {
|
|
@@ -35,6 +49,7 @@ export interface CsrDevContext {
|
|
|
35
49
|
refreshFile: string | null;
|
|
36
50
|
/** Bare specifiers the page already loads (an SSR page's import map): required as `vendor:<specifier>`, not compiled. */
|
|
37
51
|
externals?: readonly string[];
|
|
52
|
+
barrelImports?: readonly string[];
|
|
38
53
|
}
|
|
39
54
|
|
|
40
55
|
export interface CsrDevSharedHelpers {
|
|
@@ -52,6 +67,7 @@ export interface CsrDevCompiledModule {
|
|
|
52
67
|
deps: string[];
|
|
53
68
|
mtimeMs: number;
|
|
54
69
|
hash: string;
|
|
70
|
+
narrowed?: Record<string, CsrDevNarrowedImport>;
|
|
55
71
|
}
|
|
56
72
|
|
|
57
73
|
/** Module factories by id and helper definitions by hash, kept in memory so app.js is written without a pass over the disk. */
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { tempDirs, writeText as write } from "../testHelpers";
|
|
4
|
+
import { CssCompiler, declaredCustomProperties } from "./cssCompiler";
|
|
5
|
+
|
|
6
|
+
const makeTempRoot = tempDirs("akan-css-compiler-");
|
|
3
7
|
|
|
4
8
|
describe("declaredCustomProperties", () => {
|
|
5
9
|
test("names what the regex-based version named", () => {
|
|
@@ -51,3 +55,61 @@ describe("declaredCustomProperties", () => {
|
|
|
51
55
|
}
|
|
52
56
|
});
|
|
53
57
|
});
|
|
58
|
+
|
|
59
|
+
describe("CssCompiler.discoverCssAndSources", () => {
|
|
60
|
+
test("scans the files a page reaches through a client barrel's narrowed imports, never the barrel's other modules", async () => {
|
|
61
|
+
const root = await makeTempRoot();
|
|
62
|
+
const paths = { "@apps/*": ["./apps/*"], "@libs/*": ["./libs/*"] };
|
|
63
|
+
const writeSys = async (dir: string, modules: string[]) => {
|
|
64
|
+
await write(
|
|
65
|
+
path.join(dir, "client.ts"),
|
|
66
|
+
[
|
|
67
|
+
'export * as cnst from "./lib/cnst";',
|
|
68
|
+
'export { msg, usePage } from "./lib/useClient";',
|
|
69
|
+
'export { st, RootStore } from "./lib/st";',
|
|
70
|
+
...modules.map((name) => `export { ${name} } from "./lib/${name.toLowerCase()}";`),
|
|
71
|
+
"",
|
|
72
|
+
].join("\n"),
|
|
73
|
+
);
|
|
74
|
+
await write(path.join(dir, "lib/cnst.ts"), "export const Sample = 1;\n");
|
|
75
|
+
await write(path.join(dir, "lib/useClient.ts"), "export const msg = 1;\nexport const usePage = () => msg;\n");
|
|
76
|
+
for (const name of modules) {
|
|
77
|
+
await write(
|
|
78
|
+
path.join(dir, `lib/${name.toLowerCase()}/index.ts`),
|
|
79
|
+
`import * as Zone from "./${name}.Zone";\nexport const ${name} = { Zone };\n`,
|
|
80
|
+
);
|
|
81
|
+
await write(path.join(dir, `lib/${name.toLowerCase()}/${name}.Zone.tsx`), 'export const Card = () => "p-4";\n');
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const appDir = path.join(root, "apps/demo");
|
|
85
|
+
await writeSys(appDir, ["Memo", "Other"]);
|
|
86
|
+
await writeSys(path.join(root, "libs/kit"), ["Widget"]);
|
|
87
|
+
await write(path.join(root, "libs/kit/lib/st.ts"), "export class RootStore {}\nexport const st = {};\n");
|
|
88
|
+
await write(
|
|
89
|
+
path.join(appDir, "lib/st.ts"),
|
|
90
|
+
'import { RootStore as kit } from "@libs/kit/client";\nexport class RootStore {}\nexport const st = { kit };\n',
|
|
91
|
+
);
|
|
92
|
+
await write(
|
|
93
|
+
path.join(appDir, "page/_index.tsx"),
|
|
94
|
+
'import { Memo, st } from "@apps/demo/client";\nexport default function Page() { return [Memo, st]; }\n',
|
|
95
|
+
);
|
|
96
|
+
const app = {
|
|
97
|
+
cwdPath: appDir,
|
|
98
|
+
workspace: { workspaceRoot: root },
|
|
99
|
+
getTsConfig: async () => ({ compilerOptions: { paths } }),
|
|
100
|
+
getConfig: async () => ({ barrelImports: ["@apps/demo/client", "@libs/kit/client"] }),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const { sourcePaths } = await new CssCompiler(app as never).discoverCssAndSources({ pageKeys: ["_index.tsx"] });
|
|
104
|
+
|
|
105
|
+
expect(sourcePaths.map((file) => path.relative(root, file)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
|
|
106
|
+
"apps/demo/lib/memo/Memo.Zone.tsx",
|
|
107
|
+
"apps/demo/lib/memo/index.ts",
|
|
108
|
+
"apps/demo/lib/st.ts",
|
|
109
|
+
"apps/demo/lib/useClient.ts",
|
|
110
|
+
"apps/demo/page/_index.tsx",
|
|
111
|
+
"libs/kit/lib/st.ts",
|
|
112
|
+
"libs/kit/lib/useClient.ts",
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { App } from "../commandDecorators";
|
|
3
|
+
import { BarrelAnalyzer, type PackageEntry } from "../transforms/barrelAnalyzer";
|
|
4
|
+
import { createTsconfigPackageResolver, rewriteBarrelImports } from "../transforms/barrelImportsPlugin";
|
|
5
|
+
import { CsrDevPaths } from "./csrDevPaths";
|
|
6
|
+
import type { CsrDevBarrelStamp, CsrDevNarrowedImport } from "./csrDevTypes";
|
|
7
|
+
|
|
8
|
+
export interface DevBarrelRewrite {
|
|
9
|
+
contents: string | null;
|
|
10
|
+
narrowed: Record<string, CsrDevNarrowedImport>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
//* Targets are written as relative paths, which resolve against the disk with no resolution build: a save that adds a
|
|
14
|
+
//* barrel import stays a patch.
|
|
15
|
+
export class DevBarrelImports {
|
|
16
|
+
readonly #barrels: string[];
|
|
17
|
+
readonly #resolvePackage: (pkgName: string) => Promise<PackageEntry | null>;
|
|
18
|
+
readonly #analyzer: BarrelAnalyzer;
|
|
19
|
+
readonly #packages = new Map<string, Promise<PackageEntry | null>>();
|
|
20
|
+
|
|
21
|
+
constructor(barrels: readonly string[], resolvePackage: (pkgName: string) => Promise<PackageEntry | null>) {
|
|
22
|
+
this.#barrels = [...barrels];
|
|
23
|
+
this.#resolvePackage = resolvePackage;
|
|
24
|
+
this.#analyzer = new BarrelAnalyzer({ resolvePackage });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static async create(app: App, barrels: readonly string[]): Promise<DevBarrelImports | null> {
|
|
28
|
+
if (barrels.length === 0) return null;
|
|
29
|
+
return new DevBarrelImports(barrels, await createTsconfigPackageResolver(app));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async rewrite(source: string, importer: string): Promise<DevBarrelRewrite | null> {
|
|
33
|
+
const named = this.#barrels.filter((barrel) => source.includes(barrel));
|
|
34
|
+
if (named.length === 0) return null;
|
|
35
|
+
const packages = await this.#packagesOf(named);
|
|
36
|
+
const from = path.dirname(CsrDevPaths.realpath(importer));
|
|
37
|
+
const names = new Map<string, Set<string>>();
|
|
38
|
+
const contents = await rewriteBarrelImports(source, named, this.#analyzer, {
|
|
39
|
+
keepLines: true,
|
|
40
|
+
specifierOf: (subpath) => DevBarrelImports.#relative(subpath, from, packages),
|
|
41
|
+
onImport: (barrel, imported) => names.set(barrel, new Set([...(names.get(barrel) ?? []), ...imported])),
|
|
42
|
+
});
|
|
43
|
+
const narrowed = await Promise.all(
|
|
44
|
+
[...names].map(async ([barrel, set]) => {
|
|
45
|
+
const sorted = [...set].sort();
|
|
46
|
+
return [barrel, { names: sorted, hash: await this.fingerprint(barrel, sorted) }] as const;
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
return { contents, narrowed: Object.fromEntries(narrowed) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
//? Hashes what an import of these names alone narrows to, with each target as the file it resolves to: the same
|
|
53
|
+
//? names in another importer narrow to the same files, and only a moved export or a moved file changes it.
|
|
54
|
+
async fingerprint(barrel: string, names: string[]): Promise<string> {
|
|
55
|
+
const packages = await this.#packagesOf([barrel]);
|
|
56
|
+
const aliased = names.map((name, index) => `${name} as _${index}`).join(", ");
|
|
57
|
+
const narrowed = await rewriteBarrelImports(
|
|
58
|
+
`import { ${aliased} } from ${JSON.stringify(barrel)};`,
|
|
59
|
+
[barrel],
|
|
60
|
+
this.#analyzer,
|
|
61
|
+
{ specifierOf: (subpath) => DevBarrelImports.#fileOf(subpath, packages) ?? subpath },
|
|
62
|
+
);
|
|
63
|
+
return Bun.hash(narrowed ?? "").toString(36);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async stamps(barrels: Iterable<string>, idOf: (file: string) => string): Promise<Record<string, CsrDevBarrelStamp>> {
|
|
67
|
+
const stamped = await Promise.all(
|
|
68
|
+
[...barrels].map(async (barrel) => {
|
|
69
|
+
const files = await this.#analyzer.filesOf(barrel);
|
|
70
|
+
const read = [...new Set([...files, ...files.map((file) => path.dirname(file))])];
|
|
71
|
+
const stamp: CsrDevBarrelStamp = {
|
|
72
|
+
read: Object.fromEntries(read.map((file) => [idOf(file), CsrDevPaths.mtimeOf(file)])),
|
|
73
|
+
digest: await this.#digest(barrel),
|
|
74
|
+
};
|
|
75
|
+
return [barrel, stamp] as const;
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
return Object.fromEntries(stamped);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
//? Every input of an importer's fingerprint: an importer is hashed again only once this moves, so a save that
|
|
82
|
+
//? changes a component's body costs one analysis of its barrel, whatever the number of importers.
|
|
83
|
+
async #digest(barrel: string): Promise<string> {
|
|
84
|
+
const map = await this.#analyzer.analyze(barrel);
|
|
85
|
+
const packages = await this.#packagesOf([barrel]);
|
|
86
|
+
const files = new Map<string, string>();
|
|
87
|
+
const fileOf = (subpath: string) => {
|
|
88
|
+
let file = files.get(subpath);
|
|
89
|
+
if (file === undefined) {
|
|
90
|
+
file = DevBarrelImports.#fileOf(subpath, packages) ?? subpath;
|
|
91
|
+
files.set(subpath, file);
|
|
92
|
+
}
|
|
93
|
+
return file;
|
|
94
|
+
};
|
|
95
|
+
const exports = [...(map ?? [])]
|
|
96
|
+
.map(([name, target]) => [name, target.originalName, !!target.namespace, fileOf(target.subpath)] as const)
|
|
97
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
98
|
+
return Bun.hash(JSON.stringify([fileOf(barrel), map === null, exports])).toString(36);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async #packagesOf(barrels: string[]): Promise<PackageEntry[]> {
|
|
102
|
+
return (await Promise.all(barrels.map((barrel) => this.#packageOf(barrel))))
|
|
103
|
+
.filter((pkg): pkg is PackageEntry => pkg !== null)
|
|
104
|
+
.sort((a, b) => b.pkgName.length - a.pkgName.length);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#packageOf(barrel: string): Promise<PackageEntry | null> {
|
|
108
|
+
let pkg = this.#packages.get(barrel);
|
|
109
|
+
if (!pkg) {
|
|
110
|
+
pkg = this.#resolvePackage(barrel);
|
|
111
|
+
this.#packages.set(barrel, pkg);
|
|
112
|
+
}
|
|
113
|
+
return pkg;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static #fileOf(subpath: string, packages: PackageEntry[]): string | null {
|
|
117
|
+
const pkg = packages.find(({ pkgName }) => subpath === pkgName || subpath.startsWith(`${pkgName}/`));
|
|
118
|
+
if (!pkg) return null;
|
|
119
|
+
const file = CsrDevPaths.resolveOnDisk(path.join(pkg.pkgDir, subpath.slice(pkg.pkgName.length)));
|
|
120
|
+
return file ? CsrDevPaths.realpath(file) : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
static #relative(subpath: string, from: string, packages: PackageEntry[]): string {
|
|
124
|
+
const file = DevBarrelImports.#fileOf(subpath, packages);
|
|
125
|
+
if (!file) return subpath;
|
|
126
|
+
const relative = path.relative(from, file);
|
|
127
|
+
//? A target on another Windows drive has no relative path: `path.relative` returns it absolute.
|
|
128
|
+
if (path.isAbsolute(relative)) return file.split(path.sep).join("/");
|
|
129
|
+
const posix = relative.split(path.sep).join("/");
|
|
130
|
+
return posix.startsWith("../") ? posix : `./${posix}`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -7,7 +7,8 @@ import { CsrDevModuleCompiler } from "./csrDevModuleCompiler";
|
|
|
7
7
|
import { CsrDevPatcher } from "./csrDevPatcher";
|
|
8
8
|
import { CsrDevPaths } from "./csrDevPaths";
|
|
9
9
|
import { CsrDevResolver } from "./csrDevResolver";
|
|
10
|
-
import type { CsrDevCompiledModule, CsrDevContext, CsrDevGraph } from "./csrDevTypes";
|
|
10
|
+
import type { CsrDevBarrelStamp, CsrDevCompiledModule, CsrDevContext, CsrDevGraph } from "./csrDevTypes";
|
|
11
|
+
import { DevBarrelImports } from "./devBarrelImports";
|
|
11
12
|
import { RegistryMetadataFingerprint } from "./registryMetadataFingerprint";
|
|
12
13
|
import { WatchRootResolver } from "./watchRootResolver";
|
|
13
14
|
|
|
@@ -131,7 +132,7 @@ export abstract class DevRegistryBundler {
|
|
|
131
132
|
const resolver = new CsrDevResolver({ paths: this.paths, context, entryFiles: roots });
|
|
132
133
|
await resolver.prepass();
|
|
133
134
|
const compiler = this.compiler(context, resolver);
|
|
134
|
-
const { modules: compiled } = await compiler.compile(
|
|
135
|
+
const { modules: compiled, barrels } = await compiler.compile(
|
|
135
136
|
[...roots, ...(context.refreshFile ? [context.refreshFile] : [])],
|
|
136
137
|
new Set(),
|
|
137
138
|
);
|
|
@@ -147,7 +148,7 @@ export abstract class DevRegistryBundler {
|
|
|
147
148
|
pending: [],
|
|
148
149
|
metadata,
|
|
149
150
|
};
|
|
150
|
-
this.merge(graph, resolver, compiled);
|
|
151
|
+
this.merge(graph, resolver, compiled, barrels);
|
|
151
152
|
await this.writer.markBuilding();
|
|
152
153
|
await this.writer.writeModules(compiled);
|
|
153
154
|
const vendorFile = await this.writer.writeVendor(graph);
|
|
@@ -170,7 +171,16 @@ export abstract class DevRegistryBundler {
|
|
|
170
171
|
});
|
|
171
172
|
}
|
|
172
173
|
|
|
173
|
-
|
|
174
|
+
async barrelImports(context: CsrDevContext): Promise<DevBarrelImports | null> {
|
|
175
|
+
return await DevBarrelImports.create(this.app, context.barrelImports ?? []);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
merge(
|
|
179
|
+
graph: CsrDevGraph,
|
|
180
|
+
resolver: CsrDevResolver,
|
|
181
|
+
compiled: CsrDevCompiledModule[],
|
|
182
|
+
barrels: Record<string, CsrDevBarrelStamp> = {},
|
|
183
|
+
): void {
|
|
174
184
|
for (const module of compiled)
|
|
175
185
|
graph.modules[module.id] = {
|
|
176
186
|
vendor: module.vendor,
|
|
@@ -178,7 +188,12 @@ export abstract class DevRegistryBundler {
|
|
|
178
188
|
hash: module.hash,
|
|
179
189
|
deps: module.deps.map((dep) => (CsrDevPaths.isStub(dep) ? dep : this.paths.idOf(dep))),
|
|
180
190
|
...(module.helpers ? { helpers: module.helpers.hash } : {}),
|
|
191
|
+
...(module.narrowed ? { narrowed: module.narrowed } : {}),
|
|
181
192
|
};
|
|
193
|
+
const narrowing = new Set(Object.values(graph.modules).flatMap((module) => Object.keys(module.narrowed ?? {})));
|
|
194
|
+
graph.barrels = Object.fromEntries(
|
|
195
|
+
Object.entries({ ...graph.barrels, ...barrels }).filter(([barrel]) => narrowing.has(barrel)),
|
|
196
|
+
);
|
|
182
197
|
//? Only what this round compiled leaves pending: a root another route's check failed on stays for its retry.
|
|
183
198
|
const compiledIds = new Set(compiled.map((module) => module.id));
|
|
184
199
|
graph.pending = graph.pending.filter((id) => !compiledIds.has(id));
|
|
@@ -177,6 +177,7 @@ describe("McpAppViewBuilder.build", () => {
|
|
|
177
177
|
dist: { cwdPath: file("dist/apps/demo") },
|
|
178
178
|
workspace: { workspaceRoot: root },
|
|
179
179
|
getTsConfig: async () => ({ compilerOptions: {} }),
|
|
180
|
+
getConfig: async () => ({ barrelImports: [] }),
|
|
180
181
|
getPublicEnv: () => ({}),
|
|
181
182
|
logger: { warn: () => undefined },
|
|
182
183
|
};
|
|
@@ -195,6 +196,72 @@ describe("McpAppViewBuilder.build", () => {
|
|
|
195
196
|
});
|
|
196
197
|
});
|
|
197
198
|
|
|
199
|
+
describe("McpAppViewBuilder.build with an app barrel", () => {
|
|
200
|
+
test("narrows the app's client barrel to the module the trimmed page names, never another module's UI", async () => {
|
|
201
|
+
const root = await makeTempRoot();
|
|
202
|
+
const file = (rel: string) => path.join(root, rel);
|
|
203
|
+
const paths = { "@apps/*": ["./apps/*"] };
|
|
204
|
+
await write(file("tsconfig.json"), JSON.stringify({ compilerOptions: { baseUrl: ".", paths } }));
|
|
205
|
+
await write(
|
|
206
|
+
file("node_modules/akanjs/package.json"),
|
|
207
|
+
JSON.stringify({ name: "akanjs", exports: { "./webkit": "./webkit.ts" } }),
|
|
208
|
+
);
|
|
209
|
+
await write(
|
|
210
|
+
file("node_modules/akanjs/webkit.ts"),
|
|
211
|
+
"export const McpAppBoot = { run: (view: unknown) => console.info(view) };\n",
|
|
212
|
+
);
|
|
213
|
+
await write(
|
|
214
|
+
file("apps/demo/client.ts"),
|
|
215
|
+
[
|
|
216
|
+
'export { usePage } from "./lib/useClient";',
|
|
217
|
+
'export { st } from "./lib/st";',
|
|
218
|
+
'export { Memo } from "./lib/memo";',
|
|
219
|
+
'export { Report } from "./lib/report";',
|
|
220
|
+
"",
|
|
221
|
+
].join("\n"),
|
|
222
|
+
);
|
|
223
|
+
await write(
|
|
224
|
+
file("apps/demo/lib/useClient.ts"),
|
|
225
|
+
'console.info("the client runtime loaded");\nexport const usePage = () => null;\n',
|
|
226
|
+
);
|
|
227
|
+
await write(file("apps/demo/lib/st.ts"), 'console.info("the store loaded");\nexport const st = null;\n');
|
|
228
|
+
await write(file("apps/demo/lib/memo/index.ts"), 'export const Memo = "a memo card";\n');
|
|
229
|
+
await write(
|
|
230
|
+
file("apps/demo/lib/report/index.ts"),
|
|
231
|
+
'console.info("the report module loaded");\nexport const Report = "a report card";\n',
|
|
232
|
+
);
|
|
233
|
+
await write(
|
|
234
|
+
file("apps/demo/page/home.tsx"),
|
|
235
|
+
[
|
|
236
|
+
'import { Memo, Report } from "@apps/demo/client";',
|
|
237
|
+
"const page = () => ({ app: (name: string, description: string, draw: () => unknown) => ({ render: (render: () => unknown) => [name, description, draw, render] }) });",
|
|
238
|
+
'export default page().app("showHome", "Show home.", () => Memo).render(() => Report);',
|
|
239
|
+
].join("\n"),
|
|
240
|
+
);
|
|
241
|
+
const app = {
|
|
242
|
+
cwdPath: file("apps/demo"),
|
|
243
|
+
dist: { cwdPath: file("dist/apps/demo") },
|
|
244
|
+
workspace: { workspaceRoot: root },
|
|
245
|
+
getTsConfig: async () => ({ compilerOptions: { paths } }),
|
|
246
|
+
getConfig: async () => ({ barrelImports: ["@apps/demo/client"] }),
|
|
247
|
+
getPublicEnv: () => ({}),
|
|
248
|
+
logger: { warn: () => undefined },
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
await new McpAppViewBuilder(app as never, "build").build(
|
|
252
|
+
[{ key: "./home.tsx", moduleAbsPath: file("apps/demo/page/home.tsx") }],
|
|
253
|
+
{ basePaths: [], cssAssets: {}, options: CsrArtifactBuilder.bundleOptions(app as never, "build") },
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const view = await Bun.file(file("dist/apps/demo/mcp-apps/showHome.html")).text();
|
|
257
|
+
expect(view).toContain("a memo card");
|
|
258
|
+
expect(view).toContain("the client runtime loaded");
|
|
259
|
+
expect(view).toContain("the store loaded");
|
|
260
|
+
expect(view).not.toContain("a report card");
|
|
261
|
+
expect(view).not.toContain("the report module loaded");
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
198
265
|
describe("McpAppViewBuilder.entryOf", () => {
|
|
199
266
|
test("boots the one app with its page, the root layout and overrides it sits under, by relative path", () => {
|
|
200
267
|
const view = {
|
|
@@ -5,6 +5,7 @@ import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
|
5
5
|
import { computeRouteSeedIndex } from "../artifact/routeSeedIndex";
|
|
6
6
|
import { StoreBootstrap } from "../artifact/storeBootstrap";
|
|
7
7
|
import type { App } from "../commandDecorators";
|
|
8
|
+
import { workspaceBarrels } from "../transforms/barrelImportsPlugin";
|
|
8
9
|
import { bundleDefine } from "./bundleDefine";
|
|
9
10
|
import type { ClientEntryDiscovery } from "./clientBuildTypes";
|
|
10
11
|
import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
|
|
@@ -17,7 +18,7 @@ import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
|
|
|
17
18
|
//* (the app's client runtime) and every `"use client"` entry; React and the akanjs vendor facets stay the import map's,
|
|
18
19
|
//* so the page keeps one React and one store registry.
|
|
19
20
|
export class SsrDevBundler extends DevRegistryBundler {
|
|
20
|
-
static readonly #formatVersion =
|
|
21
|
+
static readonly #formatVersion = 4;
|
|
21
22
|
static readonly bootstrapKey = "";
|
|
22
23
|
readonly reloadsOnEntryChange = false;
|
|
23
24
|
#discovery: ClientEntryDiscovery | null = null;
|
|
@@ -33,8 +34,17 @@ export class SsrDevBundler extends DevRegistryBundler {
|
|
|
33
34
|
const akanConfig = await this.app.getConfig();
|
|
34
35
|
const define = bundleDefine(this.app, "start", "ssr");
|
|
35
36
|
const optimizeImports = [...akanConfig.optimizeImports];
|
|
37
|
+
const barrelImports = workspaceBarrels(akanConfig.barrelImports);
|
|
36
38
|
const configKey = Bun.hash(
|
|
37
|
-
JSON.stringify([
|
|
39
|
+
JSON.stringify([
|
|
40
|
+
"ssr",
|
|
41
|
+
SsrDevBundler.#formatVersion,
|
|
42
|
+
Bun.version,
|
|
43
|
+
define,
|
|
44
|
+
optimizeImports,
|
|
45
|
+
VENDOR_SPECIFIERS,
|
|
46
|
+
barrelImports,
|
|
47
|
+
]),
|
|
38
48
|
).toString(36);
|
|
39
49
|
return {
|
|
40
50
|
pageEntries: [],
|
|
@@ -45,6 +55,7 @@ export class SsrDevBundler extends DevRegistryBundler {
|
|
|
45
55
|
configKey,
|
|
46
56
|
refreshFile: null,
|
|
47
57
|
externals: VENDOR_SPECIFIERS,
|
|
58
|
+
barrelImports,
|
|
48
59
|
};
|
|
49
60
|
}
|
|
50
61
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.2.0-rc.
|
|
3
|
+
"version": "3.2.0-rc.3",
|
|
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.
|
|
44
|
+
"@akanjs/language": "3.2.0-rc.3",
|
|
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.
|
|
53
|
+
"akanjs": "3.2.0-rc.3",
|
|
54
54
|
"chalk": "^6.0.1",
|
|
55
55
|
"commander": "^15.0.0",
|
|
56
56
|
"dayjs": "^1.11.23",
|
package/preview/PreviewView.ts
CHANGED
|
@@ -80,12 +80,14 @@ export class PreviewView {
|
|
|
80
80
|
const view = PreviewView.#open({ width, height, engine, onConsole: (line) => logs.push(line) });
|
|
81
81
|
try {
|
|
82
82
|
//? A browser that missed its start bound may still come up, so a later page retries it within only its own bound.
|
|
83
|
+
//? One that died or was closed instead is spawned afresh by the next view, which gets the full bound.
|
|
83
84
|
const startMs = PreviewView.#stalled.has(engine) ? Math.min(startTimeoutMs, timeoutMs) : startTimeoutMs;
|
|
84
85
|
const stalled = new Error(`Timed out starting the ${engine} browser after ${startMs / 1_000} s.`);
|
|
85
86
|
await PreviewView.#within(view.navigate("about:blank"), Date.now() + startMs, stalled).then(
|
|
86
87
|
() => PreviewView.#stalled.delete(engine),
|
|
87
88
|
(error: unknown) => {
|
|
88
89
|
if (error === stalled) PreviewView.#stalled.add(engine);
|
|
90
|
+
else PreviewView.#stalled.delete(engine);
|
|
89
91
|
throw error;
|
|
90
92
|
},
|
|
91
93
|
);
|