@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
|
@@ -2,6 +2,7 @@ import { afterAll, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { createTsconfigPackageResolver } from "../transforms/barrelImportsPlugin";
|
|
5
6
|
import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
|
|
6
7
|
|
|
7
8
|
const dirs: string[] = [];
|
|
@@ -57,6 +58,28 @@ describe("GraphClientEntryDiscovery", () => {
|
|
|
57
58
|
expect(await discovery.discover([file("S.tsx")])).toEqual([file("ui/Card.ts"), file("ui/Chart/index.tsx")]);
|
|
58
59
|
});
|
|
59
60
|
|
|
61
|
+
test("an export moved into a client file of a barrel is found through the importer that names it", async () => {
|
|
62
|
+
const file = await tempDir();
|
|
63
|
+
await Bun.write(file("ui/index.ts"), 'export * from "./Foo";\nexport * from "./Other";\n');
|
|
64
|
+
await Bun.write(file("ui/Foo.tsx"), "export const Foo = 1;\nexport const Bar = () => null;\n");
|
|
65
|
+
await Bun.write(file("ui/Other.tsx"), '"use client";\nexport const Other = () => null;\n');
|
|
66
|
+
await Bun.write(file("S.tsx"), 'import { Bar } from "@demo/ui";\nexport const s = Bar;\n');
|
|
67
|
+
const resolvePackage = async (pkgName: string) => {
|
|
68
|
+
const leaf = pkgName === "@demo/ui" ? "index.ts" : `${pkgName.slice("@demo/ui/".length)}.tsx`;
|
|
69
|
+
return { pkgName, entryFile: file(`ui/${leaf}`), pkgDir: file("ui") };
|
|
70
|
+
};
|
|
71
|
+
const discovery = new GraphClientEntryDiscovery({ barrelImports: ["@demo/ui"] }, resolvePackage as never);
|
|
72
|
+
expect(await discovery.discover([file("S.tsx")])).toEqual([]);
|
|
73
|
+
|
|
74
|
+
await Bun.write(file("ui/Foo.tsx"), "export const Foo = 1;\n");
|
|
75
|
+
await Bun.write(
|
|
76
|
+
file("ui/Other.tsx"),
|
|
77
|
+
'"use client";\nexport const Other = () => null;\nexport const Bar = () => null;\n',
|
|
78
|
+
);
|
|
79
|
+
discovery.invalidate([file("ui/Foo.tsx"), file("ui/Other.tsx")]);
|
|
80
|
+
expect(await discovery.discover([file("S.tsx")])).toEqual([file("ui/Other.tsx")]);
|
|
81
|
+
});
|
|
82
|
+
|
|
60
83
|
test("a resolution still in flight when its target appears is not what the next walk reads", async () => {
|
|
61
84
|
const file = await tempDir();
|
|
62
85
|
await Bun.write(file("S.tsx"), 'import "./B";\n');
|
|
@@ -100,6 +123,35 @@ describe("GraphClientEntryDiscovery", () => {
|
|
|
100
123
|
expect(await discovery.discover([file("M5.tsx")])).toEqual([file("C.tsx")]);
|
|
101
124
|
});
|
|
102
125
|
|
|
126
|
+
test("a root layout importing System by its file under a tsconfig wildcard reaches System's client entries", async () => {
|
|
127
|
+
const file = await tempDir();
|
|
128
|
+
const workspaceRoot = path.dirname(file("x"));
|
|
129
|
+
await Bun.write(
|
|
130
|
+
file("pkgs/akanjs/ui/System/index.tsx"),
|
|
131
|
+
'import SSR from "./SSR";\nexport const System = { SSR };\n',
|
|
132
|
+
);
|
|
133
|
+
await Bun.write(
|
|
134
|
+
file("pkgs/akanjs/ui/System/SSR.tsx"),
|
|
135
|
+
'import { ClientWrapper } from "./Client";\nexport default ClientWrapper;\n',
|
|
136
|
+
);
|
|
137
|
+
await Bun.write(
|
|
138
|
+
file("pkgs/akanjs/ui/System/Client.tsx"),
|
|
139
|
+
'"use client";\nexport const ClientWrapper = () => null;\n',
|
|
140
|
+
);
|
|
141
|
+
await Bun.write(
|
|
142
|
+
file("root/__root_layout.tsx"),
|
|
143
|
+
'import { System } from "akanjs/ui/System/index.tsx";\nexport default System;\n',
|
|
144
|
+
);
|
|
145
|
+
const resolvePackage = await createTsconfigPackageResolver({
|
|
146
|
+
workspace: { workspaceRoot },
|
|
147
|
+
getTsConfig: async () => ({ compilerOptions: { paths: { "akanjs/ui/*": ["./pkgs/akanjs/ui/*"] } } }),
|
|
148
|
+
} as never);
|
|
149
|
+
const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, resolvePackage);
|
|
150
|
+
expect(await discovery.discover([file("root/__root_layout.tsx")])).toEqual([
|
|
151
|
+
file("pkgs/akanjs/ui/System/Client.tsx"),
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
|
|
103
155
|
test("a file walked inside an import cycle keeps no partial result for a walk that starts at it", async () => {
|
|
104
156
|
const file = await tempDir();
|
|
105
157
|
await Bun.write(file("A.tsx"), 'import "./B";\nimport "./C";\n');
|
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { SOURCE_EXTS } from "../akanApp/devHostPolicy";
|
|
4
4
|
import type { App } from "../commandDecorators";
|
|
5
|
-
import { BarrelAnalyzer } from "../transforms/barrelAnalyzer";
|
|
5
|
+
import { BarrelAnalyzer, type BarrelExportMap } from "../transforms/barrelAnalyzer";
|
|
6
6
|
import { createTsconfigPackageResolver, rewriteBarrelImports } from "../transforms/barrelImportsPlugin";
|
|
7
7
|
import type { AkanConfig, ClientEntryDiscovery, ScannedImport } from "./clientBuildTypes";
|
|
8
8
|
|
|
@@ -33,6 +33,9 @@ interface DiscoveryWalk {
|
|
|
33
33
|
memo: Map<string, Set<string>>;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
const exportsKeyOf = (map: BarrelExportMap | null): string =>
|
|
37
|
+
JSON.stringify(map ? [...map].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) : null);
|
|
38
|
+
|
|
36
39
|
const shouldSkipNodeModule = (absPath: string) => NODE_MODULES_RE.test(absPath) && !AKANJS_NODE_MODULE_RE.test(absPath);
|
|
37
40
|
|
|
38
41
|
/** `"use client"` discovery over the import graph (dynamic imports included), flattening barrels as the bundler does. */
|
|
@@ -48,6 +51,11 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
48
51
|
#resolvedSpecifierCache = new Map<string, Lookup<string | null>>();
|
|
49
52
|
#reachableEntriesCache = new Map<string, Set<string>>();
|
|
50
53
|
#readFiles = new Set<string>();
|
|
54
|
+
#barrelImporters = new Map<string, Set<string>>();
|
|
55
|
+
//? Barrels a save went beside, by the analysis before it: a save of any component re-reads its barrel, and only an
|
|
56
|
+
//? export that moved sends that barrel's importers back to the disk.
|
|
57
|
+
#reread = new Map<string, Promise<BarrelExportMap | null>>();
|
|
58
|
+
#settled: Promise<void> = Promise.resolve();
|
|
51
59
|
//? Bumped by every invalidation: a walk that began before one computed from facts it may have read stale, so it
|
|
52
60
|
//? answers its caller but caches nothing (a registry check walks this instance beside the slow lane's route builds).
|
|
53
61
|
#epoch = 0;
|
|
@@ -63,6 +71,8 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
async discover(seeds: string[]): Promise<string[]> {
|
|
74
|
+
this.#settled = this.#settled.then(() => this.#dropRenarrowed());
|
|
75
|
+
await this.#settled;
|
|
66
76
|
const entries = new Set<string>();
|
|
67
77
|
for (const seed of seeds) {
|
|
68
78
|
const walk: DiscoveryWalk = { visiting: new Set(), partial: new Set(), memo: new Map() };
|
|
@@ -85,6 +95,8 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
85
95
|
this.#epoch += 1;
|
|
86
96
|
// Reachable-entry results are transitive, so a changed child invalidates every ancestor.
|
|
87
97
|
this.#reachableEntriesCache.clear();
|
|
98
|
+
for (const [barrel, before] of this.#analyzer.forget(files))
|
|
99
|
+
if (!this.#reread.has(barrel)) this.#reread.set(barrel, before);
|
|
88
100
|
//? A file gone, or one never read, can leave a found resolution on its old target (`Chart.tsx` to
|
|
89
101
|
//? `Chart/index.tsx`), so a move drops them all; a save of a file already read keeps them (a walk re-resolving
|
|
90
102
|
//? every package cost 3x). Misses lapse with the epoch on their own (#lookup).
|
|
@@ -94,6 +106,17 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
94
106
|
this.#resolvedSpecifierCache.clear();
|
|
95
107
|
}
|
|
96
108
|
|
|
109
|
+
//? An importer's facts hold the files its barrel imports narrowed to, which an export moved between them changes.
|
|
110
|
+
async #dropRenarrowed(): Promise<void> {
|
|
111
|
+
const reread = [...this.#reread];
|
|
112
|
+
this.#reread.clear();
|
|
113
|
+
for (const [barrel, before] of reread) {
|
|
114
|
+
const [was, now] = await Promise.all([before, this.#analyzer.analyze(barrel)]);
|
|
115
|
+
if (exportsKeyOf(was) === exportsKeyOf(now)) continue;
|
|
116
|
+
for (const importer of this.#barrelImporters.get(barrel) ?? []) this.#factsCache.delete(importer);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
97
120
|
//? A found answer outlives later epochs; a miss, or a lookup still in flight, answers only walks of its own epoch, so
|
|
98
121
|
//? a file created meanwhile is looked up again.
|
|
99
122
|
#lookup<T>(cache: Map<string, Lookup<T>>, key: string, run: () => Promise<T>, isFound: (value: T) => boolean) {
|
|
@@ -133,15 +156,17 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
133
156
|
if (content === null) return null;
|
|
134
157
|
this.#readFiles.add(absPath);
|
|
135
158
|
if (USE_CLIENT_RE.test(content)) return { isClientEntry: true, imports: [] };
|
|
136
|
-
return { isClientEntry: false, imports: this.#scanImports(await this.#rewrite(content)) };
|
|
159
|
+
return { isClientEntry: false, imports: this.#scanImports(await this.#rewrite(content, absPath)) };
|
|
137
160
|
})();
|
|
138
161
|
this.#factsCache.set(absPath, cached);
|
|
139
162
|
}
|
|
140
163
|
return cached;
|
|
141
164
|
}
|
|
142
165
|
|
|
143
|
-
async #rewrite(content: string): Promise<string> {
|
|
166
|
+
async #rewrite(content: string, absPath: string): Promise<string> {
|
|
144
167
|
if (this.#akanConfig.barrelImports.length === 0) return content;
|
|
168
|
+
for (const barrel of this.#akanConfig.barrelImports.filter((barrel) => content.includes(barrel)))
|
|
169
|
+
this.#barrelImporters.set(barrel, (this.#barrelImporters.get(barrel) ?? new Set()).add(absPath));
|
|
145
170
|
try {
|
|
146
171
|
return (await rewriteBarrelImports(content, this.#akanConfig.barrelImports, this.#analyzer)) ?? content;
|
|
147
172
|
} catch {
|
|
@@ -125,3 +125,51 @@ console.info(await result.outputs[0].text());
|
|
|
125
125
|
expect(stdout.trim()).toBe("a");
|
|
126
126
|
});
|
|
127
127
|
});
|
|
128
|
+
|
|
129
|
+
describe("CsrArtifactBuilder.barrelImportsPlugin", () => {
|
|
130
|
+
test("narrows an app's client barrel to the runtime, the store and the modules a page names", async () => {
|
|
131
|
+
const root = await makeTempRoot();
|
|
132
|
+
const appDir = path.join(root, "apps/demo");
|
|
133
|
+
const paths = { "@apps/*": ["./apps/*"] };
|
|
134
|
+
await writeText(path.join(root, "tsconfig.json"), JSON.stringify({ compilerOptions: { baseUrl: ".", paths } }));
|
|
135
|
+
await writeText(
|
|
136
|
+
path.join(appDir, "client.ts"),
|
|
137
|
+
[
|
|
138
|
+
'export { usePage } from "./lib/useClient";',
|
|
139
|
+
'export { st } from "./lib/st";',
|
|
140
|
+
'export { Memo } from "./lib/memo";',
|
|
141
|
+
'export { Other } from "./lib/other";',
|
|
142
|
+
"",
|
|
143
|
+
].join("\n"),
|
|
144
|
+
);
|
|
145
|
+
await writeText(path.join(appDir, "lib/useClient.ts"), 'console.info("runtime");\nexport const usePage = 1;\n');
|
|
146
|
+
await writeText(path.join(appDir, "lib/st.ts"), 'console.info("store");\nexport const st = 2;\n');
|
|
147
|
+
await writeText(path.join(appDir, "lib/memo/index.ts"), 'console.info("memo-ui");\nexport const Memo = 3;\n');
|
|
148
|
+
await writeText(path.join(appDir, "lib/other/index.ts"), 'console.info("other-ui");\nexport const Other = 4;\n');
|
|
149
|
+
await writeText(path.join(appDir, "page.ts"), 'import { Memo } from "@apps/demo/client";\nconsole.info(Memo);\n');
|
|
150
|
+
const app = {
|
|
151
|
+
workspace: { workspaceRoot: root },
|
|
152
|
+
getConfig: async () => ({ barrelImports: ["akanjs/ui", "@apps/demo/client"] }),
|
|
153
|
+
getTsConfig: async () => ({ compilerOptions: { paths } }),
|
|
154
|
+
getPublicEnv: () => ({}),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const options = CsrArtifactBuilder.bundleOptions(app as never, "build");
|
|
158
|
+
const result = await Bun.build({
|
|
159
|
+
...options,
|
|
160
|
+
plugins: [...options.plugins, await CsrArtifactBuilder.barrelImportsPlugin(app as never)],
|
|
161
|
+
entrypoints: [path.join(appDir, "page.ts")],
|
|
162
|
+
});
|
|
163
|
+
expect(result.success).toBe(true);
|
|
164
|
+
await Bun.write(path.join(root, "out.js"), (await result.outputs[0]?.text()) ?? "");
|
|
165
|
+
const proc = Bun.spawn([process.execPath, "out.js"], { cwd: root, stdout: "pipe", stderr: "pipe" });
|
|
166
|
+
const [stdout, stderr] = await Promise.all([
|
|
167
|
+
new Response(proc.stdout).text(),
|
|
168
|
+
new Response(proc.stderr).text(),
|
|
169
|
+
proc.exited,
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
expect(stderr).toBe("");
|
|
173
|
+
expect(stdout.trim().split("\n")).toEqual(["runtime", "store", "memo-ui", "3"]);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -3,7 +3,11 @@ import path from "node:path";
|
|
|
3
3
|
import type { BaseBuildArtifact } from "akanjs/server";
|
|
4
4
|
import { type PageEntry, resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
5
5
|
import type { App } from "../commandDecorators";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type BarrelImportsPluginOptions,
|
|
8
|
+
createBarrelImportsPlugin,
|
|
9
|
+
workspaceBarrels,
|
|
10
|
+
} from "../transforms/barrelImportsPlugin";
|
|
7
11
|
import { createBrowserDirnamePlugin } from "../transforms/browserDirnamePlugin";
|
|
8
12
|
import { SideEffectImportOrder } from "../transforms/sideEffectImportOrder";
|
|
9
13
|
import { bundleConditions, bundleDefine } from "./bundleDefine";
|
|
@@ -121,9 +125,11 @@ export class CsrArtifactBuilder {
|
|
|
121
125
|
};
|
|
122
126
|
}
|
|
123
127
|
|
|
124
|
-
//? Whole, the akanjs/ui barrel bundles every component and the optional peers (react-icons) they import
|
|
128
|
+
//? Whole, the akanjs/ui barrel bundles every component and the optional peers (react-icons) they import, and an app or
|
|
129
|
+
//? lib client barrel every module's UI.
|
|
125
130
|
static async barrelImportsPlugin(app: App, pipeAfter?: BarrelImportsPluginOptions["pipeAfter"]) {
|
|
126
|
-
|
|
131
|
+
const barrels = ["akanjs/ui", ...workspaceBarrels((await app.getConfig()).barrelImports)];
|
|
132
|
+
return await createBarrelImportsPlugin(app, { barrels, pipeAfter });
|
|
127
133
|
}
|
|
128
134
|
|
|
129
135
|
/** Where a build records the `AKAN_PUBLIC_*` it inlined, which nothing in the minified bundle names. */
|
|
@@ -4,6 +4,7 @@ import { CSR_DEV_DIRNAME, CSR_DEV_ROUTE_PREFIX } from "akanjs/server/hmr/csrDevM
|
|
|
4
4
|
import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
5
5
|
import type { App } from "../commandDecorators";
|
|
6
6
|
import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
|
|
7
|
+
import { workspaceBarrels } from "../transforms/barrelImportsPlugin";
|
|
7
8
|
import { bundleDefine } from "./bundleDefine";
|
|
8
9
|
import { CsrDevPaths } from "./csrDevPaths";
|
|
9
10
|
import type { CsrDevContext } from "./csrDevTypes";
|
|
@@ -13,7 +14,7 @@ import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
|
|
|
13
14
|
|
|
14
15
|
//* Dev-only CSR as a module registry, under `.akan/artifact/csr-dev`: one entry per basePath boots every page.
|
|
15
16
|
export class CsrDevBundler extends DevRegistryBundler {
|
|
16
|
-
static readonly #formatVersion =
|
|
17
|
+
static readonly #formatVersion = 6;
|
|
17
18
|
readonly reloadsOnEntryChange = true;
|
|
18
19
|
//? Detecting a page's async default parses it with TypeScript: on apps/akan every page each save cost ~100ms.
|
|
19
20
|
readonly #asyncDefaults = new Map<string, { mtimeMs: number; detected: Promise<boolean> }>();
|
|
@@ -30,8 +31,16 @@ export class CsrDevBundler extends DevRegistryBundler {
|
|
|
30
31
|
const htmlBasePaths = basePaths.length > 0 ? basePaths : [""];
|
|
31
32
|
const define = bundleDefine(this.app, "start", "csr");
|
|
32
33
|
const optimizeImports = [...akanConfig.optimizeImports];
|
|
34
|
+
const barrelImports = workspaceBarrels(akanConfig.barrelImports);
|
|
33
35
|
const configKey = Bun.hash(
|
|
34
|
-
JSON.stringify([
|
|
36
|
+
JSON.stringify([
|
|
37
|
+
CsrDevBundler.#formatVersion,
|
|
38
|
+
Bun.version,
|
|
39
|
+
define,
|
|
40
|
+
optimizeImports,
|
|
41
|
+
htmlBasePaths,
|
|
42
|
+
barrelImports,
|
|
43
|
+
]),
|
|
35
44
|
).toString(36);
|
|
36
45
|
return {
|
|
37
46
|
pageEntries,
|
|
@@ -41,6 +50,7 @@ export class CsrDevBundler extends DevRegistryBundler {
|
|
|
41
50
|
optimizeImports,
|
|
42
51
|
configKey,
|
|
43
52
|
refreshFile: this.#resolveRefreshRuntime(),
|
|
53
|
+
barrelImports,
|
|
44
54
|
};
|
|
45
55
|
}
|
|
46
56
|
|
|
@@ -291,3 +291,100 @@ describe("CsrDevModuleCompiler", () => {
|
|
|
291
291
|
expect(String((failure as AggregateError).errors[0])).toContain('"await" can only be used');
|
|
292
292
|
});
|
|
293
293
|
});
|
|
294
|
+
|
|
295
|
+
describe("CsrDevModuleCompiler barrel narrowing", () => {
|
|
296
|
+
let root: string;
|
|
297
|
+
const file = (relative: string) => path.join(root, relative);
|
|
298
|
+
const paths = { "@apps/*": ["./apps/*"], "@libs/*": ["./libs/*"] };
|
|
299
|
+
const compile = async (relative: string, { prepass }: { prepass: boolean }) => {
|
|
300
|
+
const narrowing = { ...context, barrelImports: ["@apps/demo/client"] };
|
|
301
|
+
const devPaths = new CsrDevPaths(root);
|
|
302
|
+
const resolver = new CsrDevResolver({ paths: devPaths, context: narrowing, entryFiles: [file(relative)] });
|
|
303
|
+
if (prepass) await resolver.prepass();
|
|
304
|
+
const compiler = new CsrDevModuleCompiler({
|
|
305
|
+
app: {
|
|
306
|
+
verbose: () => undefined,
|
|
307
|
+
logger: { error: () => undefined },
|
|
308
|
+
workspace: { workspaceRoot: root },
|
|
309
|
+
getTsConfig: async () => ({ compilerOptions: { paths } }),
|
|
310
|
+
} as unknown as App,
|
|
311
|
+
paths: devPaths,
|
|
312
|
+
resolver,
|
|
313
|
+
context: narrowing,
|
|
314
|
+
outDir: file("out"),
|
|
315
|
+
routePrefix: "/_akan/registry/",
|
|
316
|
+
});
|
|
317
|
+
return await compiler.compile([file(relative)], new Set(), { refusePrepass: !prepass });
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
beforeAll(async () => {
|
|
321
|
+
root = CsrDevPaths.realpath(await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-barrels-")));
|
|
322
|
+
await Bun.write(file("tsconfig.json"), JSON.stringify({ compilerOptions: { baseUrl: ".", paths } }));
|
|
323
|
+
await Bun.write(
|
|
324
|
+
file("apps/demo/client.ts"),
|
|
325
|
+
[
|
|
326
|
+
'export * as cnst from "./lib/cnst";',
|
|
327
|
+
'export { msg, usePage } from "./lib/useClient";',
|
|
328
|
+
'export { st, RootStore } from "./lib/st";',
|
|
329
|
+
'export { Memo } from "./lib/memo";',
|
|
330
|
+
'export { Other } from "./lib/other";',
|
|
331
|
+
"",
|
|
332
|
+
].join("\n"),
|
|
333
|
+
);
|
|
334
|
+
await Bun.write(file("apps/demo/lib/cnst.ts"), "export const Sample = 1;\n");
|
|
335
|
+
await Bun.write(file("apps/demo/lib/useClient.ts"), "export const msg = 1;\nexport const usePage = () => msg;\n");
|
|
336
|
+
await Bun.write(file("apps/demo/lib/st.ts"), "export class RootStore {}\nexport const st = new RootStore();\n");
|
|
337
|
+
for (const name of ["Memo", "Other"]) {
|
|
338
|
+
await Bun.write(
|
|
339
|
+
file(`apps/demo/lib/${name.toLowerCase()}/index.ts`),
|
|
340
|
+
`import * as Zone from "./${name}.Zone";\nexport const ${name} = { Zone };\n`,
|
|
341
|
+
);
|
|
342
|
+
await Bun.write(
|
|
343
|
+
file(`apps/demo/lib/${name.toLowerCase()}/${name}.Zone.tsx`),
|
|
344
|
+
`export const Card = () => "${name}";\n`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
await Bun.write(
|
|
348
|
+
file("apps/demo/page/home.tsx"),
|
|
349
|
+
'import { Memo, st } from "@apps/demo/client";\nexport const Page = () => [Memo.Zone.Card, st];\n',
|
|
350
|
+
);
|
|
351
|
+
await Bun.write(
|
|
352
|
+
file("apps/demo/page/added.tsx"),
|
|
353
|
+
'import { Other } from "@apps/demo/client";\nexport const Page = () => Other.Zone.Card;\n',
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
afterAll(async () => {
|
|
358
|
+
await rm(root, { recursive: true, force: true });
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("a page compiles the client runtime, the store and the modules it names, never the barrel or another module", async () => {
|
|
362
|
+
const { modules } = await compile("apps/demo/page/home.tsx", { prepass: true });
|
|
363
|
+
const ids = modules.map((module) => module.id).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
364
|
+
|
|
365
|
+
expect(ids).toEqual([
|
|
366
|
+
"apps/demo/lib/memo/Memo.Zone.tsx",
|
|
367
|
+
"apps/demo/lib/memo/index.ts",
|
|
368
|
+
"apps/demo/lib/st.ts",
|
|
369
|
+
"apps/demo/lib/useClient.ts",
|
|
370
|
+
"apps/demo/page/home.tsx",
|
|
371
|
+
]);
|
|
372
|
+
const page = modules.find((module) => module.id === "apps/demo/page/home.tsx");
|
|
373
|
+
const [sourceContent] = (JSON.parse(page?.sourceMap ?? "{}") as { sourcesContent?: string[] }).sourcesContent ?? [];
|
|
374
|
+
expect(sourceContent?.split("\n")).toHaveLength(3);
|
|
375
|
+
expect(sourceContent).toContain('import "../lib/useClient.ts"; import "../lib/st.ts";');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("a save that adds a barrel import compiles without the resolution build a new bare import needs", async () => {
|
|
379
|
+
const { modules, unresolved } = await compile("apps/demo/page/added.tsx", { prepass: false });
|
|
380
|
+
|
|
381
|
+
expect(unresolved).toEqual([]);
|
|
382
|
+
expect(modules.map((module) => module.id).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
|
|
383
|
+
"apps/demo/lib/other/Other.Zone.tsx",
|
|
384
|
+
"apps/demo/lib/other/index.ts",
|
|
385
|
+
"apps/demo/lib/st.ts",
|
|
386
|
+
"apps/demo/lib/useClient.ts",
|
|
387
|
+
"apps/demo/page/added.tsx",
|
|
388
|
+
]);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import type { BunPlugin } from "bun";
|
|
3
3
|
import type { App } from "../commandDecorators";
|
|
4
|
+
import { loaderFor } from "../transforms/moduleSyntax";
|
|
4
5
|
import { CsrDevFactoryCheck } from "./csrDevFactoryCheck";
|
|
5
6
|
import { CsrDevPaths } from "./csrDevPaths";
|
|
6
7
|
import { CsrDevResolver } from "./csrDevResolver";
|
|
7
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
CsrDevBarrelStamp,
|
|
10
|
+
CsrDevCompiledModule,
|
|
11
|
+
CsrDevContext,
|
|
12
|
+
CsrDevNarrowedImport,
|
|
13
|
+
CsrDevSharedHelpers,
|
|
14
|
+
} from "./csrDevTypes";
|
|
15
|
+
import { DevBarrelImports } from "./devBarrelImports";
|
|
8
16
|
import { RouteClientBuilder } from "./routeClientBuilder";
|
|
9
17
|
|
|
10
18
|
interface OutputMetafile {
|
|
@@ -38,6 +46,7 @@ export interface CsrDevCompileResult {
|
|
|
38
46
|
modules: CsrDevCompiledModule[];
|
|
39
47
|
refusedVendors: string[];
|
|
40
48
|
unresolved: string[];
|
|
49
|
+
barrels?: Record<string, CsrDevBarrelStamp>;
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
//* Every module becomes its own CJS factory: one Bun.build with each import external, so a save re-runs only the
|
|
@@ -58,6 +67,8 @@ export class CsrDevModuleCompiler {
|
|
|
58
67
|
readonly #outDir: string;
|
|
59
68
|
readonly #routePrefix: string;
|
|
60
69
|
readonly #assets = new Set<string>();
|
|
70
|
+
#barrels: Promise<DevBarrelImports | null> | null = null;
|
|
71
|
+
readonly #narrowed = new Map<string, Record<string, CsrDevNarrowedImport>>();
|
|
61
72
|
|
|
62
73
|
constructor({ app, paths, resolver, context, outDir, routePrefix }: CsrDevModuleCompilerOptions) {
|
|
63
74
|
this.#app = app;
|
|
@@ -109,7 +120,14 @@ export class CsrDevModuleCompiler {
|
|
|
109
120
|
this.#app.verbose(
|
|
110
121
|
`[csr-dev] ${fallbacks.size} import(s) outside the browser build's graph resolved with Bun's runtime resolver: ${[...fallbacks].slice(0, 5).join(", ")}${fallbacks.size > 5 ? ", ..." : ""}`,
|
|
111
122
|
);
|
|
112
|
-
|
|
123
|
+
const modules = [...compiled.values()];
|
|
124
|
+
return { modules, refusedVendors: [], unresolved: [], barrels: await this.#barrelStamps(modules) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async #barrelStamps(modules: CsrDevCompiledModule[]): Promise<Record<string, CsrDevBarrelStamp>> {
|
|
128
|
+
const barrels = new Set(modules.flatMap((module) => Object.keys(module.narrowed ?? {})));
|
|
129
|
+
const barrelImports = barrels.size > 0 ? await this.#barrelImports() : null;
|
|
130
|
+
return barrelImports ? await barrelImports.stamps(barrels, (file) => this.#paths.idOf(file)) : {};
|
|
113
131
|
}
|
|
114
132
|
|
|
115
133
|
async #compileRound(files: string[]): Promise<Omit<BuildRound, "tangled">> {
|
|
@@ -197,6 +215,7 @@ export class CsrDevModuleCompiler {
|
|
|
197
215
|
continue;
|
|
198
216
|
}
|
|
199
217
|
const sourceMap = sourceMaps.get(`${artifact.path}.map`);
|
|
218
|
+
const narrowed = this.#narrowed.get(file);
|
|
200
219
|
modules.push({
|
|
201
220
|
id,
|
|
202
221
|
file,
|
|
@@ -206,6 +225,7 @@ export class CsrDevModuleCompiler {
|
|
|
206
225
|
deps: this.#emittedDeps(code),
|
|
207
226
|
mtimeMs: mtimes.get(file) ?? CsrDevPaths.mtimeOf(file),
|
|
208
227
|
hash: hashes.get(file) ?? "",
|
|
228
|
+
...(narrowed && Object.keys(narrowed).length > 0 ? { narrowed } : {}),
|
|
209
229
|
});
|
|
210
230
|
}
|
|
211
231
|
return { modules, misses, tangled };
|
|
@@ -302,13 +322,17 @@ export class CsrDevModuleCompiler {
|
|
|
302
322
|
});
|
|
303
323
|
}
|
|
304
324
|
if (!vendor)
|
|
305
|
-
build.onLoad({ filter: /\.tsx$/ }, async (args) => {
|
|
325
|
+
build.onLoad({ filter: /\.(?:tsx?|jsx?)$/ }, async (args) => {
|
|
306
326
|
if (CsrDevPaths.isVendorFile(args.path)) return undefined;
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
327
|
+
const source = await Bun.file(args.path).text();
|
|
328
|
+
const rewrite = await (await this.#barrelImports())?.rewrite(source, args.path);
|
|
329
|
+
if (rewrite) this.#narrowed.set(CsrDevPaths.realpath(args.path), rewrite.narrowed);
|
|
330
|
+
const narrowed = rewrite?.contents ?? source;
|
|
331
|
+
const normalized = args.path.endsWith(".tsx")
|
|
332
|
+
? RouteClientBuilder.normalizeNamedDefaultFunctionForFastRefresh(narrowed, { path: args.path })
|
|
333
|
+
: null;
|
|
334
|
+
const contents = normalized ?? narrowed;
|
|
335
|
+
return contents === source ? undefined : { contents, loader: loaderFor(args.path) };
|
|
312
336
|
});
|
|
313
337
|
build.onResolve({ filter: /.*/ }, (args) => {
|
|
314
338
|
if (args.kind === "entry-point-build" || !args.importer) return undefined;
|
|
@@ -334,6 +358,11 @@ export class CsrDevModuleCompiler {
|
|
|
334
358
|
};
|
|
335
359
|
}
|
|
336
360
|
|
|
361
|
+
#barrelImports(): Promise<DevBarrelImports | null> {
|
|
362
|
+
this.#barrels ??= DevBarrelImports.create(this.#app, this.#context.barrelImports ?? []);
|
|
363
|
+
return this.#barrels;
|
|
364
|
+
}
|
|
365
|
+
|
|
337
366
|
//? Bun writes sources relative to the working directory; the page shows them by workspace path instead. A package
|
|
338
367
|
//? barrel inlined into its importer (react-icons) would carry its whole text into every importer's map.
|
|
339
368
|
#rebaseSourceMap(text: string): string {
|
|
@@ -119,12 +119,25 @@ describe("CsrDevPatcher", () => {
|
|
|
119
119
|
workspace: { workspaceRoot: root },
|
|
120
120
|
cwdPath: path.join(root, "apps/demo"),
|
|
121
121
|
verbose: () => undefined,
|
|
122
|
+
getTsConfig: async () => ({ compilerOptions: { paths: tsPaths } }),
|
|
122
123
|
};
|
|
123
124
|
super(app as unknown as App, { dirName: "registry", routePrefix: "/_akan/registry/", library: true });
|
|
124
125
|
this.#entry = entry;
|
|
125
126
|
}
|
|
127
|
+
barrels: string[] = [];
|
|
126
128
|
async context() {
|
|
127
|
-
return context;
|
|
129
|
+
return { ...context, barrelImports: this.barrels };
|
|
130
|
+
}
|
|
131
|
+
fingerprints = 0;
|
|
132
|
+
override async barrelImports(barrelContext: CsrDevContext) {
|
|
133
|
+
const barrelImports = await super.barrelImports(barrelContext);
|
|
134
|
+
if (!barrelImports) return null;
|
|
135
|
+
const fingerprint = barrelImports.fingerprint.bind(barrelImports);
|
|
136
|
+
barrelImports.fingerprint = async (barrel, names) => {
|
|
137
|
+
this.fingerprints += 1;
|
|
138
|
+
return await fingerprint(barrel, names);
|
|
139
|
+
};
|
|
140
|
+
return barrelImports;
|
|
128
141
|
}
|
|
129
142
|
async writeEntries() {
|
|
130
143
|
return { files: { "": this.#entry }, changed: [] };
|
|
@@ -145,13 +158,22 @@ describe("CsrDevPatcher", () => {
|
|
|
145
158
|
}
|
|
146
159
|
}
|
|
147
160
|
|
|
148
|
-
const
|
|
161
|
+
const tsPaths = { "@apps/*": ["./apps/*"] };
|
|
162
|
+
const movedRegistry = async (
|
|
163
|
+
files: Record<string, string>,
|
|
164
|
+
{ roots = [], barrels = [] }: { roots?: string[]; barrels?: string[] } = {},
|
|
165
|
+
) => {
|
|
149
166
|
const root = await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-move-"));
|
|
150
167
|
dirs.push(root);
|
|
151
168
|
const file = (relative: string) => path.join(CsrDevPaths.realpath(root), "apps/demo", relative);
|
|
152
169
|
for (const [relative, text] of Object.entries(files)) await Bun.write(file(relative), text);
|
|
170
|
+
await Bun.write(
|
|
171
|
+
path.join(root, "tsconfig.json"),
|
|
172
|
+
JSON.stringify({ compilerOptions: { baseUrl: ".", paths: tsPaths } }),
|
|
173
|
+
);
|
|
153
174
|
const bundler = new MovedRegistry(root, file("entry.ts"));
|
|
154
175
|
bundler.extraRoots = roots.map((relative) => file(relative));
|
|
176
|
+
bundler.barrels = barrels;
|
|
155
177
|
expect(await bundler.update([])).toMatchObject({ generation: 1, reload: true, first: true });
|
|
156
178
|
const patcher = new CsrDevPatcher(bundler, { resident: true });
|
|
157
179
|
expect(await patcher.update([])).toEqual({ kind: "unchanged" });
|
|
@@ -562,6 +584,71 @@ describe("CsrDevPatcher", () => {
|
|
|
562
584
|
expect(await app()).toContain('__akan.define("apps/demo/ui/Foo.tsx"');
|
|
563
585
|
});
|
|
564
586
|
|
|
587
|
+
test("an export moved to another file of a narrowed barrel compiles the importers of that name, also after a restart", async () => {
|
|
588
|
+
const { bundler, patcher, file, app, changedIds } = await movedRegistry(
|
|
589
|
+
{
|
|
590
|
+
"ui/index.ts": 'export * from "./Foo";\n',
|
|
591
|
+
"ui/Foo.tsx": 'export const Foo = () => "foo";\nexport const Bar = () => "bar";\n',
|
|
592
|
+
"page.ts": 'import { Foo } from "@apps/demo/ui";\nexport const page = Foo;\n',
|
|
593
|
+
"entry.ts":
|
|
594
|
+
'import { Bar } from "@apps/demo/ui";\nimport { page } from "./page";\nexport const run = () => Bar() + page();\n',
|
|
595
|
+
},
|
|
596
|
+
{ barrels: ["@apps/demo/ui"] },
|
|
597
|
+
);
|
|
598
|
+
const factoryOf = (text: string, id: string) =>
|
|
599
|
+
text.slice(text.indexOf(`__akan.define("${id}"`)).split("__akan.define(")[1];
|
|
600
|
+
|
|
601
|
+
await Bun.write(file("ui/Foo.tsx"), 'export const Foo = () => "foo v2";\nexport const Bar = () => "bar";\n');
|
|
602
|
+
expect(changedIds(await patcher.update([file("ui/Foo.tsx")]))).toEqual(["apps/demo/ui/Foo.tsx"]);
|
|
603
|
+
expect(bundler.fingerprints).toBe(0);
|
|
604
|
+
|
|
605
|
+
await Bun.write(file("ui/Foo.tsx"), 'export const Foo = () => "foo";\n');
|
|
606
|
+
await Bun.write(file("ui/Bar.tsx"), 'export const Bar = () => "bar moved";\n');
|
|
607
|
+
await Bun.write(file("ui/index.ts"), 'export * from "./Bar";\nexport * from "./Foo";\n');
|
|
608
|
+
const moved = await patcher.update([file("ui/Foo.tsx"), file("ui/Bar.tsx"), file("ui/index.ts")]);
|
|
609
|
+
expect(changedIds(moved)).toEqual(["apps/demo/entry.ts", "apps/demo/ui/Bar.tsx", "apps/demo/ui/Foo.tsx"]);
|
|
610
|
+
expect(bundler.fingerprints).toBe(2);
|
|
611
|
+
expect(factoryOf(await app(), "apps/demo/entry.ts")).toContain('require("akan-module:apps/demo/ui/Bar.tsx")');
|
|
612
|
+
|
|
613
|
+
patcher.forget();
|
|
614
|
+
await Bun.write(file("ui/Foo.tsx"), 'export const Foo = () => "foo";\nexport const Bar = () => "bar back";\n');
|
|
615
|
+
await Bun.write(file("ui/Bar.tsx"), "export const Gone = 1;\n");
|
|
616
|
+
const restarted = await bundler.update([]);
|
|
617
|
+
expect(restarted?.changedIds.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
|
|
618
|
+
"apps/demo/entry.ts",
|
|
619
|
+
"apps/demo/ui/Bar.tsx",
|
|
620
|
+
"apps/demo/ui/Foo.tsx",
|
|
621
|
+
]);
|
|
622
|
+
expect(factoryOf(await app(), "apps/demo/entry.ts")).toContain('require("akan-module:apps/demo/ui/Foo.tsx")');
|
|
623
|
+
expect(await new CsrDevPatcher(bundler).update([])).toEqual({ kind: "unchanged" });
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("a move whose compile fails leaves the barrel's stamps on disk, so the next save reads it again", async () => {
|
|
627
|
+
const { bundler, file, app, changedIds } = await movedRegistry(
|
|
628
|
+
{
|
|
629
|
+
"ui/index.ts": 'export * from "./Foo";\n',
|
|
630
|
+
"ui/Foo.tsx": 'export const Foo = () => "foo";\nexport const Bar = () => "bar";\n',
|
|
631
|
+
"entry.ts": 'import { Bar } from "@apps/demo/ui";\nexport const run = Bar;\n',
|
|
632
|
+
},
|
|
633
|
+
{ barrels: ["@apps/demo/ui"] },
|
|
634
|
+
);
|
|
635
|
+
const barrelsOnDisk = async () => (await bundler.writer.readJson<CsrDevGraph>("graph.json"))?.barrels;
|
|
636
|
+
const before = await barrelsOnDisk();
|
|
637
|
+
|
|
638
|
+
await Bun.write(file("ui/Foo.tsx"), 'export const Foo = () => "foo";\n');
|
|
639
|
+
await Bun.write(file("ui/Bar.tsx"), 'import "./Missing";\nexport const Bar = () => "bar moved";\n');
|
|
640
|
+
await Bun.write(file("ui/index.ts"), 'export * from "./Bar";\nexport * from "./Foo";\n');
|
|
641
|
+
const saved = [file("ui/Foo.tsx"), file("ui/Bar.tsx"), file("ui/index.ts")];
|
|
642
|
+
await expect(new CsrDevPatcher(bundler).update(saved)).rejects.toThrow();
|
|
643
|
+
expect(await barrelsOnDisk()).toEqual(before);
|
|
644
|
+
|
|
645
|
+
await Bun.write(file("ui/Bar.tsx"), 'export const Bar = () => "bar moved";\n');
|
|
646
|
+
const fixed = await new CsrDevPatcher(bundler).update([file("ui/Bar.tsx")]);
|
|
647
|
+
expect(changedIds(fixed)).toEqual(["apps/demo/entry.ts", "apps/demo/ui/Bar.tsx", "apps/demo/ui/Foo.tsx"]);
|
|
648
|
+
expect(await app()).toContain('"bar moved"');
|
|
649
|
+
expect(await barrelsOnDisk()).not.toEqual(before);
|
|
650
|
+
});
|
|
651
|
+
|
|
565
652
|
test("a registry with no manifest is a first build, which no tab holds a module of", async () => {
|
|
566
653
|
const { bundler, dir } = await makeRegistry("same");
|
|
567
654
|
await rm(path.join(dir, CSR_DEV_MANIFEST_FILE), { force: true });
|