@akanjs/devkit 3.2.0-rc.0 → 3.2.0-rc.2
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 +41 -1
- package/akanContext.ts +1 -3
- package/akanMcpContract.ts +4 -3
- 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/createTunnel.test.ts +12 -2
- package/frontendBuild/buildRouteClient.test.ts +43 -0
- package/frontendBuild/clientEntryDiscovery.test.ts +22 -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/PreviewChrome.ts +3 -3
- package/preview/PreviewView.ts +26 -8
- package/preview/previewView.test.ts +87 -3
- 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/plan.ts +54 -14
- package/workspaceGraph.test.ts +116 -3
- package/workspaceGraph.ts +6 -3
|
@@ -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.2",
|
|
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.2",
|
|
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.2",
|
|
54
54
|
"chalk": "^6.0.1",
|
|
55
55
|
"commander": "^15.0.0",
|
|
56
56
|
"dayjs": "^1.11.23",
|
package/preview/PreviewChrome.ts
CHANGED
|
@@ -12,12 +12,12 @@ export class PreviewChrome {
|
|
|
12
12
|
/** Set this to drive Chrome on macOS too, e.g. to reproduce what a pod sees. */
|
|
13
13
|
static readonly backendEnvKey = "AKAN_CODE_WEBVIEW_BACKEND";
|
|
14
14
|
|
|
15
|
-
/** The executables Bun looks for on PATH before giving up. */
|
|
15
|
+
/** The executables Bun looks for on PATH before giving up, in its order: the first found is the one it starts. */
|
|
16
16
|
static readonly executables = [
|
|
17
|
-
"chromium",
|
|
18
|
-
"chromium-browser",
|
|
19
17
|
"google-chrome-stable",
|
|
20
18
|
"google-chrome",
|
|
19
|
+
"chromium-browser",
|
|
20
|
+
"chromium",
|
|
21
21
|
"brave-browser",
|
|
22
22
|
"microsoft-edge",
|
|
23
23
|
"chrome",
|
package/preview/PreviewView.ts
CHANGED
|
@@ -5,7 +5,8 @@ export interface PreviewProbeOptions {
|
|
|
5
5
|
width?: number;
|
|
6
6
|
height?: number;
|
|
7
7
|
engine?: PreviewEngine;
|
|
8
|
-
|
|
8
|
+
startTimeoutMs?: number;
|
|
9
|
+
/** Bounds the probe once the browser is up: a dev server that is recovering takes the connection and never answers. */
|
|
9
10
|
timeoutMs?: number;
|
|
10
11
|
/** Waited after the load, so the errors effects throw land before the page is read. */
|
|
11
12
|
settleMs?: number;
|
|
@@ -45,6 +46,10 @@ interface PageRead {
|
|
|
45
46
|
// Bun.WebView rather than a downloaded browser: on macOS its webkit backend needs no Chromium at all.
|
|
46
47
|
// It is experimental, so its whole surface stays behind this adapter; a Bun minor that changes it breaks one file.
|
|
47
48
|
export class PreviewView {
|
|
49
|
+
/** A view's first navigation includes the browser's start, which took ~35 s on a cold CI runner. */
|
|
50
|
+
static readonly startTimeoutMs = 120_000;
|
|
51
|
+
static readonly #stalled = new Set<PreviewEngine>();
|
|
52
|
+
|
|
48
53
|
static get available() {
|
|
49
54
|
return PreviewView.isAvailable();
|
|
50
55
|
}
|
|
@@ -66,15 +71,31 @@ export class PreviewView {
|
|
|
66
71
|
width = 1280,
|
|
67
72
|
height = 800,
|
|
68
73
|
engine = PreviewChrome.backend,
|
|
74
|
+
startTimeoutMs = PreviewView.startTimeoutMs,
|
|
69
75
|
timeoutMs = 30_000,
|
|
70
76
|
settleMs = 0,
|
|
71
77
|
}: PreviewProbeOptions = {},
|
|
72
78
|
): Promise<PreviewProbeResult> {
|
|
73
79
|
const logs: string[] = [];
|
|
74
|
-
const deadline = Date.now() + timeoutMs;
|
|
75
|
-
const within = <T>(work: Promise<T>, what: string) => PreviewView.#within(work, deadline, `${what} ${url}`);
|
|
76
80
|
const view = PreviewView.#open({ width, height, engine, onConsole: (line) => logs.push(line) });
|
|
77
81
|
try {
|
|
82
|
+
//? A browser that missed its start bound may still come up, so a later page retries it within only its own bound.
|
|
83
|
+
const startMs = PreviewView.#stalled.has(engine) ? Math.min(startTimeoutMs, timeoutMs) : startTimeoutMs;
|
|
84
|
+
const stalled = new Error(`Timed out starting the ${engine} browser after ${startMs / 1_000} s.`);
|
|
85
|
+
await PreviewView.#within(view.navigate("about:blank"), Date.now() + startMs, stalled).then(
|
|
86
|
+
() => PreviewView.#stalled.delete(engine),
|
|
87
|
+
(error: unknown) => {
|
|
88
|
+
if (error === stalled) PreviewView.#stalled.add(engine);
|
|
89
|
+
throw error;
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
const deadline = Date.now() + timeoutMs;
|
|
93
|
+
const within = <T>(work: Promise<T>, what: string) =>
|
|
94
|
+
PreviewView.#within(
|
|
95
|
+
work,
|
|
96
|
+
deadline,
|
|
97
|
+
new Error(`Timed out ${what} ${url}; the dev server may be rebuilding or stuck.`),
|
|
98
|
+
);
|
|
78
99
|
let page = await PreviewView.#load(view, url, settleMs, within);
|
|
79
100
|
//? Right after a save the first load can render nothing (`[rscClient] module not loaded`); the next one has it.
|
|
80
101
|
if (page.empty) {
|
|
@@ -126,13 +147,10 @@ export class PreviewView {
|
|
|
126
147
|
return response?.status ?? null;
|
|
127
148
|
}
|
|
128
149
|
|
|
129
|
-
static async #within<T>(work: Promise<T>, deadline: number,
|
|
150
|
+
static async #within<T>(work: Promise<T>, deadline: number, expiry: Error): Promise<T> {
|
|
130
151
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
131
152
|
const expired = new Promise<never>((_, reject) => {
|
|
132
|
-
timer = setTimeout(
|
|
133
|
-
() => reject(new Error(`Timed out ${what}; the dev server may be rebuilding or stuck.`)),
|
|
134
|
-
Math.max(0, deadline - Date.now()),
|
|
135
|
-
);
|
|
153
|
+
timer = setTimeout(() => reject(expiry), Math.max(0, deadline - Date.now()));
|
|
136
154
|
});
|
|
137
155
|
try {
|
|
138
156
|
return await Promise.race([work, expired]);
|
|
@@ -1,9 +1,42 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { PreviewChrome } from "./PreviewChrome";
|
|
2
6
|
import { PreviewView } from "./PreviewView";
|
|
3
7
|
|
|
4
8
|
const serve = (html: string, status = 200) =>
|
|
5
9
|
Bun.serve({ port: 0, fetch: () => new Response(html, { status, headers: { "content-type": "text/html" } }) });
|
|
6
10
|
|
|
11
|
+
const browserTestMs = PreviewView.startTimeoutMs + 30_000;
|
|
12
|
+
const slowChromeRuns = PreviewView.isAvailable("chrome") && process.platform !== "win32";
|
|
13
|
+
|
|
14
|
+
const withSlowChrome = async (body: string, startSeconds = 3) => {
|
|
15
|
+
const dir = await mkdtemp(path.join(tmpdir(), "akan-slow-chrome-"));
|
|
16
|
+
const chrome = path.join(dir, "chrome");
|
|
17
|
+
await writeFile(chrome, `#!/bin/sh\nsleep ${startSeconds}\nexec "${PreviewChrome.executable()}" "$@"\n`);
|
|
18
|
+
await chmod(chrome, 0o755);
|
|
19
|
+
const script = `const { PreviewView } = await import(${JSON.stringify(path.join(import.meta.dir, "PreviewView.ts"))});\n${body}`;
|
|
20
|
+
//? Chrome is spawned once per process, so only a fresh process takes the slow executable.
|
|
21
|
+
const child = Bun.spawn([process.execPath, "-e", script], {
|
|
22
|
+
env: { ...process.env, [PreviewChrome.pathEnvKey]: chrome },
|
|
23
|
+
stdout: "pipe",
|
|
24
|
+
stderr: "pipe",
|
|
25
|
+
timeout: PreviewView.startTimeoutMs + 15_000,
|
|
26
|
+
});
|
|
27
|
+
try {
|
|
28
|
+
const [out, err, code] = await Promise.all([
|
|
29
|
+
new Response(child.stdout).text(),
|
|
30
|
+
new Response(child.stderr).text(),
|
|
31
|
+
child.exited,
|
|
32
|
+
]);
|
|
33
|
+
return { code, out: out.trim(), err };
|
|
34
|
+
} finally {
|
|
35
|
+
child.kill();
|
|
36
|
+
await rm(dir, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
7
40
|
describe("PreviewView", () => {
|
|
8
41
|
test("reports whether this Bun build can drive a headless page at all", () => {
|
|
9
42
|
expect(typeof PreviewView.available).toBe("boolean");
|
|
@@ -29,7 +62,7 @@ describe("PreviewView", () => {
|
|
|
29
62
|
server.stop(true);
|
|
30
63
|
}
|
|
31
64
|
},
|
|
32
|
-
|
|
65
|
+
browserTestMs,
|
|
33
66
|
);
|
|
34
67
|
|
|
35
68
|
test.skipIf(!PreviewView.available)(
|
|
@@ -56,7 +89,7 @@ describe("PreviewView", () => {
|
|
|
56
89
|
server.stop(true);
|
|
57
90
|
}
|
|
58
91
|
},
|
|
59
|
-
|
|
92
|
+
browserTestMs,
|
|
60
93
|
);
|
|
61
94
|
|
|
62
95
|
test.skipIf(!PreviewView.available)(
|
|
@@ -71,6 +104,57 @@ describe("PreviewView", () => {
|
|
|
71
104
|
server.stop(true);
|
|
72
105
|
}
|
|
73
106
|
},
|
|
74
|
-
|
|
107
|
+
browserTestMs,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
test.skipIf(!slowChromeRuns)(
|
|
111
|
+
"starts the page's bound once the browser is up, however long Chrome takes to spawn",
|
|
112
|
+
async () => {
|
|
113
|
+
const server = serve("<!doctype html><title>Up</title><body>up</body>");
|
|
114
|
+
try {
|
|
115
|
+
const { code, out, err } = await withSlowChrome(
|
|
116
|
+
`const probe = await PreviewView.probe("http://localhost:${server.port}/", { engine: "chrome", timeoutMs: 1_500 });
|
|
117
|
+
console.log(probe.text);`,
|
|
118
|
+
);
|
|
119
|
+
expect(out || err).toBe("up");
|
|
120
|
+
expect(code).toBe(0);
|
|
121
|
+
} finally {
|
|
122
|
+
server.stop(true);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
browserTestMs,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
test.skipIf(!slowChromeRuns)(
|
|
129
|
+
"gives a later page only its own bound for a browser that missed its start, and recovers once it is up",
|
|
130
|
+
async () => {
|
|
131
|
+
const { code, out, err } = await withSlowChrome(
|
|
132
|
+
`const open = () =>
|
|
133
|
+
PreviewView.probe("about:blank", { engine: "chrome", startTimeoutMs: 1_500, timeoutMs: 1_000 }).then(
|
|
134
|
+
() => "opened",
|
|
135
|
+
(error) => error.message,
|
|
136
|
+
);
|
|
137
|
+
console.log(await open());
|
|
138
|
+
console.log(await open());
|
|
139
|
+
let late = await open();
|
|
140
|
+
for (let tries = 0; late !== "opened" && tries < 100; tries++) late = await open();
|
|
141
|
+
console.log(late);
|
|
142
|
+
Bun.WebView.closeAll();
|
|
143
|
+
await Bun.sleep(500);
|
|
144
|
+
console.log(await open());
|
|
145
|
+
Bun.WebView.closeAll();`,
|
|
146
|
+
6,
|
|
147
|
+
);
|
|
148
|
+
expect(out || err).toBe(
|
|
149
|
+
[
|
|
150
|
+
"Timed out starting the chrome browser after 1.5 s.",
|
|
151
|
+
"Timed out starting the chrome browser after 1 s.",
|
|
152
|
+
"opened",
|
|
153
|
+
"Timed out starting the chrome browser after 1.5 s.",
|
|
154
|
+
].join("\n"),
|
|
155
|
+
);
|
|
156
|
+
expect(code).toBe(0);
|
|
157
|
+
},
|
|
158
|
+
browserTestMs,
|
|
75
159
|
);
|
|
76
160
|
});
|