@akanjs/devkit 3.0.0-beta.17 → 3.0.0-beta.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/abstractDoc.ts +5 -4
- package/akanApp/akanApp.host.ts +23 -9
- package/akanConfig/akanConfig.test.ts +55 -30
- package/akanConfig/akanConfig.ts +29 -17
- package/akanConfig/types.ts +1 -0
- package/applicationBuildRunner.test.ts +5 -3
- package/applicationBuildRunner.ts +33 -6
- package/artifact/implicitRootLayout.test.ts +6 -11
- package/artifact/implicitRootLayout.ts +0 -15
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/biome.base.json +6 -1
- package/capacitorApp.test.ts +28 -2
- package/capacitorApp.ts +25 -4
- package/cloud/constants.ts +23 -0
- package/cloud/globalConfig.ts +13 -0
- package/codeAgent/agent/AkanCodeServices.ts +38 -12
- package/codeAgent/agent/CodeAgent.ts +94 -30
- package/codeAgent/agent/CodeAgentAsks.ts +3 -1
- package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
- package/codeAgent/agent/CodeAgentProxy.ts +91 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
- package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
- package/codeAgent/agent/akanCodeModel.ts +1 -1
- package/codeAgent/agent/akanCodePaths.ts +23 -3
- package/codeAgent/agent/akanContextFiles.test.ts +2 -1
- package/codeAgent/agent/akanSystemPrompt.ts +5 -0
- package/codeAgent/agent/codeAgentContract.test.ts +17 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- package/codeAgent/skills/akan-code/SKILL.md +122 -0
- package/codeAgent/skills/akan-module/SKILL.md +2 -2
- package/codeAgent/skills/akan-store/SKILL.md +6 -2
- package/codeAgent/tools/AkanCodePlugins.ts +19 -3
- package/codeAgent/tools/AskToolPack.ts +111 -0
- package/codeAgent/tools/McpClient.ts +51 -5
- package/codeAgent/tools/McpOAuth.ts +268 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +77 -0
- package/codeAgent/tools/McpServerConfig.ts +69 -34
- package/codeAgent/tools/McpSignIn.ts +161 -0
- package/codeAgent/tools/McpTokenStore.ts +92 -0
- package/codeAgent/tools/McpToolPack.ts +16 -3
- package/codeAgent/tools/askToolPack.test.ts +119 -0
- package/codeAgent/tools/mcpOAuth.test.ts +324 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +106 -12
- package/commandDecorators/command.ts +2 -0
- package/commandDecorators/commandDecorators.test.ts +1 -1
- package/commandDecorators/helpFormatter.ts +2 -1
- package/dependencyScanner.ts +4 -2
- package/devkitUtils.test.ts +1 -1
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +18 -46
- package/executors.ts +31 -8
- package/frontendBuild/autoImportSync.test.ts +13 -0
- package/frontendBuild/autoImportSync.ts +19 -1
- package/frontendBuild/buildRouteClient.test.ts +52 -1
- package/frontendBuild/cssImportResolver.ts +1 -1
- package/frontendBuild/fontOptimizer.test.ts +1 -1
- package/frontendBuild/frontendBuild.test.ts +19 -17
- package/frontendBuild/hmrWatcher.test.ts +1 -1
- package/frontendBuild/routeClientBuilder.ts +27 -10
- package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
- package/getDirname.test.ts +4 -2
- package/incrementalBuilder/devWatchBatch.test.ts +7 -6
- package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
- package/index.ts +0 -1
- package/integration/devStabilityHarness.test.ts +7 -2
- package/lint/gritRules.test.ts +2 -1
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/linter.ts +4 -1
- package/package.json +2 -2
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +64 -0
- package/platformTest/IdleWatchedProcess.ts +77 -0
- package/platformTest/LinuxDockerTarget.ts +120 -0
- package/platformTest/PlatformTestRun.test.ts +68 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +120 -0
- package/platformTest/TestSnapshot.test.ts +80 -0
- package/platformTest/TestSnapshot.ts +107 -0
- package/platformTest/WindowsSshTarget.ts +167 -0
- package/prompter.ts +0 -7
- package/qualityScanner.test.ts +3 -2
- package/qualityScanner.ts +1 -1
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +0 -12
- package/scanInfo.ts +5 -2
- package/subspace.test.ts +5 -2
- package/subspace.ts +19 -8
- package/workflow/executor.ts +52 -0
- package/workflow/rolloutGate.test.ts +1 -1
- package/workflow/source.ts +19 -13
- package/guideline.ts +0 -19
package/executors.test.ts
CHANGED
|
@@ -312,9 +312,17 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
312
312
|
expect(env.AKAN_PUBLIC_SERVER_PORT).toBe("8285");
|
|
313
313
|
expect(env.EXTRA).toBe("ok");
|
|
314
314
|
|
|
315
|
+
const shellOperationMode = process.env.AKAN_PUBLIC_OPERATION_MODE;
|
|
316
|
+
process.env.AKAN_PUBLIC_OPERATION_MODE = "local";
|
|
315
317
|
const prepared = await app.prepareCommand("build");
|
|
318
|
+
const bakedOperationMode = process.env.AKAN_PUBLIC_OPERATION_MODE;
|
|
319
|
+
if (shellOperationMode === undefined) delete process.env.AKAN_PUBLIC_OPERATION_MODE;
|
|
320
|
+
else process.env.AKAN_PUBLIC_OPERATION_MODE = shellOperationMode;
|
|
321
|
+
expect(bakedOperationMode).toBeUndefined();
|
|
316
322
|
expect(prepared.env.AKAN_COMMAND_TYPE).toBe("build");
|
|
317
323
|
expect(prepared.env.AKAN_PUBLIC_BASE_PATHS).toBe("admin");
|
|
324
|
+
expect(prepared.env.AKAN_DATABASE_MODE).toBe("single");
|
|
325
|
+
expect(prepared.env.AKAN_DATABASE_MODES).toBe("single");
|
|
318
326
|
// Bundling reads `process.env` through getPublicEnv and `define`s every AKAN_PUBLIC_* into a literal, so a
|
|
319
327
|
// dev port published here would be baked into the artifact and outrank the PORT the container is run with.
|
|
320
328
|
expect(process.env.AKAN_PUBLIC_APP_NAME).toBe("demo");
|
|
@@ -727,7 +735,7 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
727
735
|
});
|
|
728
736
|
});
|
|
729
737
|
|
|
730
|
-
test("accepts
|
|
738
|
+
test("accepts head route exports during page key discovery", async () => {
|
|
731
739
|
const root = await makeTempRoot();
|
|
732
740
|
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
733
741
|
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
@@ -737,24 +745,19 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
737
745
|
await writeFile(path.join(root, "apps/demo/akan.config.ts"), "export default {};\n");
|
|
738
746
|
await writeFile(
|
|
739
747
|
path.join(root, "apps/demo/page/_layout.tsx"),
|
|
740
|
-
[
|
|
741
|
-
"export const head = null;",
|
|
742
|
-
"export const metadata = { title: 'Root' };",
|
|
743
|
-
"export default function Layout({ children }) { return children; }",
|
|
744
|
-
"",
|
|
745
|
-
].join("\n"),
|
|
748
|
+
["export const head = null;", "export default function Layout({ children }) { return children; }", ""].join("\n"),
|
|
746
749
|
);
|
|
747
750
|
await writeFile(
|
|
748
751
|
path.join(root, "apps/demo/page/docs/_layout.tsx"),
|
|
749
752
|
[
|
|
750
|
-
"export async function
|
|
753
|
+
"export async function generateHead() { return null; }",
|
|
751
754
|
"export default function Layout({ children }) { return children; }",
|
|
752
755
|
"",
|
|
753
756
|
].join("\n"),
|
|
754
757
|
);
|
|
755
758
|
await writeFile(
|
|
756
759
|
path.join(root, "apps/demo/page/docs/intro.tsx"),
|
|
757
|
-
["export const
|
|
760
|
+
["export const head = null;", "export default function Page() { return null; }", ""].join("\n"),
|
|
758
761
|
);
|
|
759
762
|
|
|
760
763
|
const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
|
|
@@ -767,7 +770,7 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
767
770
|
]);
|
|
768
771
|
});
|
|
769
772
|
|
|
770
|
-
test("rejects
|
|
773
|
+
test("rejects a metadata route export during page key discovery", async () => {
|
|
771
774
|
const root = await makeTempRoot();
|
|
772
775
|
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
773
776
|
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
@@ -776,47 +779,16 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
776
779
|
await mkdir(path.join(root, "apps/demo/page"), { recursive: true });
|
|
777
780
|
await writeFile(path.join(root, "apps/demo/akan.config.ts"), "export default {};\n");
|
|
778
781
|
await writeFile(
|
|
779
|
-
path.join(root, "apps/demo/page/
|
|
780
|
-
[
|
|
781
|
-
"
|
|
782
|
-
|
|
783
|
-
"export default function Page() { return null; }",
|
|
784
|
-
"",
|
|
785
|
-
].join("\n"),
|
|
782
|
+
path.join(root, "apps/demo/page/legacy.tsx"),
|
|
783
|
+
["export const metadata = { title: 'Legacy' };", "export default function Page() { return null; }", ""].join(
|
|
784
|
+
"\n",
|
|
785
|
+
),
|
|
786
786
|
);
|
|
787
787
|
|
|
788
788
|
const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
|
|
789
789
|
const app = AppExecutor.from(workspace, "demo");
|
|
790
790
|
|
|
791
|
-
await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(
|
|
792
|
-
"metadata and generateMetadata cannot both be exported",
|
|
793
|
-
);
|
|
794
|
-
});
|
|
795
|
-
|
|
796
|
-
test("rejects mixed head and metadata route export channels during page key discovery", async () => {
|
|
797
|
-
const root = await makeTempRoot();
|
|
798
|
-
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
799
|
-
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
800
|
-
process.env.AKAN_PUBLIC_ENV = "local";
|
|
801
|
-
await writeJson(path.join(root, "package.json"), rootPackageJson());
|
|
802
|
-
await mkdir(path.join(root, "apps/demo/page"), { recursive: true });
|
|
803
|
-
await writeFile(path.join(root, "apps/demo/akan.config.ts"), "export default {};\n");
|
|
804
|
-
await writeFile(
|
|
805
|
-
path.join(root, "apps/demo/page/mixed.tsx"),
|
|
806
|
-
[
|
|
807
|
-
"export const head = null;",
|
|
808
|
-
"export const metadata = { title: 'Mixed' };",
|
|
809
|
-
"export default function Page() { return null; }",
|
|
810
|
-
"",
|
|
811
|
-
].join("\n"),
|
|
812
|
-
);
|
|
813
|
-
|
|
814
|
-
const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
|
|
815
|
-
const app = AppExecutor.from(workspace, "demo");
|
|
816
|
-
|
|
817
|
-
await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(
|
|
818
|
-
"head/generateHead and metadata/generateMetadata cannot both be exported",
|
|
819
|
-
);
|
|
791
|
+
await expect(app.getPageKeys({ refresh: true })).rejects.toThrow('unsupported export "metadata"');
|
|
820
792
|
});
|
|
821
793
|
|
|
822
794
|
test("assigns start command ports from sorted app order", async () => {
|
package/executors.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { readFileSync } from "node:fs";
|
|
|
11
11
|
import {
|
|
12
12
|
copyFile,
|
|
13
13
|
cp as cpEntry,
|
|
14
|
+
lstat,
|
|
14
15
|
mkdir,
|
|
15
16
|
readdir as readDirEntries,
|
|
16
17
|
realpath,
|
|
@@ -96,7 +97,9 @@ const staticTemplateFileExtensions = new Set([
|
|
|
96
97
|
".xml",
|
|
97
98
|
]);
|
|
98
99
|
|
|
99
|
-
|
|
100
|
+
//? A backslash is a path separator on Windows but an escape in a POSIX shell, so only there does it force quoting.
|
|
101
|
+
const plainCommandArgPattern = process.platform === "win32" ? /^[\w@%+=:,./\\-]+$/ : /^[\w@%+=:,./-]+$/;
|
|
102
|
+
const formatCommandArg = (value: string) => (plainCommandArgPattern.test(value) ? value : JSON.stringify(value));
|
|
100
103
|
|
|
101
104
|
const formatCommandForDisplay = (command: string, args: string[] = []) =>
|
|
102
105
|
[command, ...args].map(formatCommandArg).join(" ");
|
|
@@ -475,8 +478,10 @@ export class Executor {
|
|
|
475
478
|
const isDirectory = (await stat(src)).isDirectory();
|
|
476
479
|
if (!(await FileSys.exists(dest)) && isDirectory) await mkdir(dest, { recursive: true });
|
|
477
480
|
//* `cp -r` keeps symlinks on GNU coreutils but follows them on macOS, so anything that must land as
|
|
478
|
-
//* real files regardless of platform has to say so explicitly.
|
|
479
|
-
|
|
481
|
+
//* real files regardless of platform has to say so explicitly. Windows has no `cp -r <dir>/.` and a
|
|
482
|
+
//* symlink there needs a privilege most accounts lack, so it always lands as real files.
|
|
483
|
+
if (dereference || process.platform === "win32")
|
|
484
|
+
await cpEntry(src, dest, { recursive: isDirectory, dereference: true, force: true });
|
|
480
485
|
else await $`cp -r ${src}${isDirectory ? "/." : ""} ${dest}`;
|
|
481
486
|
}
|
|
482
487
|
log(msg: string) {
|
|
@@ -1405,14 +1410,21 @@ export class AppExecutor extends SysExecutor {
|
|
|
1405
1410
|
...env,
|
|
1406
1411
|
};
|
|
1407
1412
|
}
|
|
1413
|
+
/** The database mode a command runs this app in, and the modes its build carries — the same rule the server boots by. */
|
|
1414
|
+
async getDatabaseModeEnv() {
|
|
1415
|
+
const akanConfig = await this.getConfig();
|
|
1416
|
+
return {
|
|
1417
|
+
AKAN_DATABASE_MODE: akanConfig.resolveDatabaseMode(),
|
|
1418
|
+
AKAN_DATABASE_MODES: akanConfig.database.modes.join(","),
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1408
1421
|
async prepareCommand(type: "build" | "start") {
|
|
1409
1422
|
const akanConfig = await this.getConfig();
|
|
1410
|
-
const databaseMode = process.env.AKAN_DATABASE_MODE ?? akanConfig.defaultDatabaseMode ?? "single";
|
|
1411
1423
|
const routeEnv = {
|
|
1412
1424
|
AKAN_PUBLIC_BASE_PATHS: [...akanConfig.basePaths].join(","),
|
|
1413
1425
|
AKAN_PUBLIC_API_PREFIX: akanConfig.api.prefix,
|
|
1414
1426
|
AKAN_PUBLIC_WS_PREFIX: akanConfig.api.websocketPrefix,
|
|
1415
|
-
|
|
1427
|
+
...(await this.getDatabaseModeEnv()),
|
|
1416
1428
|
};
|
|
1417
1429
|
Object.assign(process.env, routeEnv);
|
|
1418
1430
|
if (type === "build") {
|
|
@@ -1457,11 +1469,14 @@ export class AppExecutor extends SysExecutor {
|
|
|
1457
1469
|
// this does not leak non-public env.
|
|
1458
1470
|
// The port keys are this machine's dev allocation, and `define` turns an `AKAN_PUBLIC_*` into a literal the
|
|
1459
1471
|
// artifact can never be run with a different value for — a baked port would outrank the `PORT` the container
|
|
1460
|
-
// is started with and send every SSR self-call to a port nothing bound.
|
|
1472
|
+
// is started with and send every SSR self-call to a port nothing bound. The operation mode likewise: one image
|
|
1473
|
+
// serves an edge site and a cloud cluster, and a shell's `local` would pin every artifact to it.
|
|
1461
1474
|
if (type === "build") {
|
|
1462
1475
|
const buildEnv = { ...env };
|
|
1463
1476
|
delete buildEnv.AKAN_PUBLIC_CLIENT_PORT;
|
|
1464
1477
|
delete buildEnv.AKAN_PUBLIC_SERVER_PORT;
|
|
1478
|
+
delete buildEnv.AKAN_PUBLIC_OPERATION_MODE;
|
|
1479
|
+
delete process.env.AKAN_PUBLIC_OPERATION_MODE;
|
|
1465
1480
|
Object.assign(process.env, buildEnv);
|
|
1466
1481
|
}
|
|
1467
1482
|
return { env };
|
|
@@ -1507,7 +1522,9 @@ export class AppExecutor extends SysExecutor {
|
|
|
1507
1522
|
const owners = new Map<string, { absPath: string; fromLib: boolean }>();
|
|
1508
1523
|
const devOnlyKeys = new Set<string>();
|
|
1509
1524
|
const devOnlyDirs: string[] = [];
|
|
1510
|
-
|
|
1525
|
+
const pageRoots = await this.getPageRoots();
|
|
1526
|
+
const libKeyPrefixes = pageRoots.map((root) => root.keyPrefix).filter(Boolean);
|
|
1527
|
+
for (const root of pageRoots) {
|
|
1511
1528
|
if (!(await FileSys.dirExists(root.dir))) continue;
|
|
1512
1529
|
for await (const rel of glob.scan({
|
|
1513
1530
|
cwd: root.dir,
|
|
@@ -1517,6 +1534,8 @@ export class AppExecutor extends SysExecutor {
|
|
|
1517
1534
|
const segments = rel.split(path.sep);
|
|
1518
1535
|
if (segments.some((s) => s === "node_modules")) continue;
|
|
1519
1536
|
const posix = `${root.keyPrefix}${segments.join("/")}`;
|
|
1537
|
+
//* A lib folder that is a copy rather than a link (the Windows fallback) is walked into from `page` too.
|
|
1538
|
+
if (!root.keyPrefix && libKeyPrefixes.some((prefix) => posix.startsWith(prefix))) continue;
|
|
1520
1539
|
const absPath = path.join(root.dir, ...segments);
|
|
1521
1540
|
validatePageSourceFile(posix, { filePath: absPath });
|
|
1522
1541
|
if (!isRouteSourceFile(posix)) continue;
|
|
@@ -1619,7 +1638,11 @@ export class AppExecutor extends SysExecutor {
|
|
|
1619
1638
|
)
|
|
1620
1639
|
).flat();
|
|
1621
1640
|
const wanted = parents.flatMap((parent) => libs.map((lib) => `${parent}${AppExecutor.#pageLibsDir}/(${lib})/`));
|
|
1622
|
-
|
|
1641
|
+
//* A copy — the Windows fallback when a junction is refused — does not follow its lib, so only links are current.
|
|
1642
|
+
const isLink = async (key: string) =>
|
|
1643
|
+
(await lstat(`${this.cwdPath}/page/${key.slice(0, -1)}`).catch(() => null))?.isSymbolicLink() ?? false;
|
|
1644
|
+
if (linked.sort().join(",") === wanted.sort().join(",") && (await Promise.all(linked.map(isLink))).every(Boolean))
|
|
1645
|
+
return false;
|
|
1623
1646
|
await Promise.all(
|
|
1624
1647
|
parents.map((parent) => this.removeDir(`${this.cwdPath}/page/${parent}${AppExecutor.#pageLibsDir}`)),
|
|
1625
1648
|
);
|
|
@@ -161,6 +161,19 @@ describe("transformSource — client role (package client entry)", () => {
|
|
|
161
161
|
);
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
+
test("never claims fetch from a file that calls the platform fetch", () => {
|
|
165
|
+
const globalOnly = `"use client";\n\nexport const toBlob = async (url: string) => await (await fetch(url)).blob();\n`;
|
|
166
|
+
const mixed = `"use client";\n\nexport const C = async (url: string) => (await fetch(url)).ok && fetch.me() && st.foo;\n`;
|
|
167
|
+
const passed = `"use client";\n\nexport const load = (get = fetch) => get("/a");\n`;
|
|
168
|
+
|
|
169
|
+
expect(transformSource(globalOnly, "CropImage.tsx", clientLibCtx)).toBeNull();
|
|
170
|
+
expect(transformSource(mixed, "C.Zone.tsx", clientLibCtx)).toBe(
|
|
171
|
+
`"use client";\nimport { st } from "@libs/shared/client";\n\nexport const C = async (url: string) => (await fetch(url)).ok && fetch.me() && st.foo;\n`,
|
|
172
|
+
);
|
|
173
|
+
expect(transformSource(passed, "load.ts", clientLibCtx)).toBeNull();
|
|
174
|
+
expect(transformSource(globalOnly, "x.store.ts", storeCtx)).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
164
177
|
test("handles webkit .ts files (no jsx) the same as tsx", () => {
|
|
165
178
|
const source = `import { getEnv } from "akanjs/base";\n\nexport const load = () => fetch.me();\n`;
|
|
166
179
|
const out = transformSource(source, "cookie.ts", clientLibCtx);
|
|
@@ -161,6 +161,10 @@ const LIB_BARRELS: Record<string, { barrel: string; kind: ImportKind }> = {
|
|
|
161
161
|
cnst: { barrel: "cnst", kind: "namespace" },
|
|
162
162
|
srv: { barrel: "srv", kind: "namespace" },
|
|
163
163
|
};
|
|
164
|
+
//* Registry names that are also runtime globals. An unbound `fetch(url)` is the platform's fetch, and importing Akan's
|
|
165
|
+
//* over it silently rebinds the call, so such a name is claimed only when every reference has the Akan member shape
|
|
166
|
+
//* (`fetch.viewX()`); one bare use leaves the file alone.
|
|
167
|
+
const GLOBAL_NAMES = new Set(["fetch"]);
|
|
164
168
|
//* ECMAScript/TS structural globals never resolved as domain symbols, so that a package model named
|
|
165
169
|
//* e.g. `Map` cannot shadow the JS `Map` used in `new Map()`. Domain-y globals like `File` are allowed
|
|
166
170
|
//* on purpose (they are real models here).
|
|
@@ -378,13 +382,14 @@ export const transformSource = (
|
|
|
378
382
|
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, scriptKindFor(fileName));
|
|
379
383
|
const bound = collectBoundNames(sf);
|
|
380
384
|
const used = collectUsedReferences(sf);
|
|
385
|
+
const bareGlobals = collectBareGlobalReferences(sf);
|
|
381
386
|
|
|
382
387
|
// Group the needed symbols by their import target: framework registry first, then dynamic resolver.
|
|
383
388
|
// Named groups map each name to whether it is type-only, so we can emit `import type`/inline `type`.
|
|
384
389
|
const namedBySpecifier = new Map<string, Map<string, boolean>>();
|
|
385
390
|
const namespaceImports: { name: string; specifier: string }[] = [];
|
|
386
391
|
for (const name of used) {
|
|
387
|
-
if (bound.has(name)) continue;
|
|
392
|
+
if (bound.has(name) || bareGlobals.has(name)) continue;
|
|
388
393
|
const target = targetFor(name, ctx) ?? resolveExtra?.(name) ?? null;
|
|
389
394
|
if (!target) continue;
|
|
390
395
|
if (target.kind === "namespace") namespaceImports.push({ name, specifier: target.specifier });
|
|
@@ -478,6 +483,19 @@ const collectUsedReferences = (sf: ts.SourceFile): Set<string> => {
|
|
|
478
483
|
return used;
|
|
479
484
|
};
|
|
480
485
|
|
|
486
|
+
const collectBareGlobalReferences = (sf: ts.SourceFile): Set<string> => {
|
|
487
|
+
const bare = new Set<string>();
|
|
488
|
+
const visit = (node: ts.Node) => {
|
|
489
|
+
if (ts.isIdentifier(node) && GLOBAL_NAMES.has(node.text) && isReferencePosition(node)) {
|
|
490
|
+
const parent = node.parent;
|
|
491
|
+
if (!(parent && ts.isPropertyAccessExpression(parent) && parent.expression === node)) bare.add(node.text);
|
|
492
|
+
}
|
|
493
|
+
ts.forEachChild(node, visit);
|
|
494
|
+
};
|
|
495
|
+
ts.forEachChild(sf, visit);
|
|
496
|
+
return bare;
|
|
497
|
+
};
|
|
498
|
+
|
|
481
499
|
//* True when the identifier reads a binding, as opposed to being a member name, property key, or
|
|
482
500
|
//* declaration name. e.g. in `cnst.Coordinate`, `cnst` is a reference but `Coordinate` is not.
|
|
483
501
|
const isReferencePosition = (node: ts.Identifier): boolean => {
|
|
@@ -27,7 +27,7 @@ afterEach(async () => {
|
|
|
27
27
|
|
|
28
28
|
describe("route client store bootstrap", () => {
|
|
29
29
|
test("wraps client entries with app client bootstrap before re-exporting components", () => {
|
|
30
|
-
const original = "/repo/pkgs/akanjs/ui/Model/NewWrapper_Client.tsx";
|
|
30
|
+
const original = path.resolve("/repo/pkgs/akanjs/ui/Model/NewWrapper_Client.tsx");
|
|
31
31
|
const source = RouteClientBuilder.createStoreBootstrapEntrySource({
|
|
32
32
|
appName: "akan",
|
|
33
33
|
originalEntry: original,
|
|
@@ -147,6 +147,57 @@ describe("route client store bootstrap", () => {
|
|
|
147
147
|
);
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
test("bundles every route's entries into one graph so a page never loads a module twice", async () => {
|
|
151
|
+
const root = await makeTempRoot();
|
|
152
|
+
const appDir = path.join(root, "apps/demo");
|
|
153
|
+
const pageA = path.join(appDir, "page/a.tsx");
|
|
154
|
+
const pageB = path.join(appDir, "page/b.tsx");
|
|
155
|
+
const entryA = path.join(appDir, "ui/A.tsx");
|
|
156
|
+
const entryB = path.join(appDir, "ui/B.tsx");
|
|
157
|
+
const shared = path.join(appDir, "common/shared.ts");
|
|
158
|
+
await write(pageA, 'import { A } from "../ui/A";\nexport default A;\n');
|
|
159
|
+
await write(pageB, 'import { B } from "../ui/B";\nexport default B;\n');
|
|
160
|
+
await write(entryA, '"use client";\nimport { mark } from "../common/shared";\nexport const A = () => mark;\n');
|
|
161
|
+
await write(entryB, '"use client";\nimport { mark } from "../common/shared";\nexport const B = () => mark;\n');
|
|
162
|
+
await write(shared, 'export const mark = "shared-module-marker";\n');
|
|
163
|
+
const app = {
|
|
164
|
+
name: "demo",
|
|
165
|
+
cwdPath: appDir,
|
|
166
|
+
dist: { cwdPath: path.join(root, "dist/apps/demo") },
|
|
167
|
+
workspace: { workspaceRoot: root },
|
|
168
|
+
getConfig: async () => ({ barrelImports: [], optimizeImports: [] }),
|
|
169
|
+
getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
|
|
170
|
+
getPublicEnv: () => ({}),
|
|
171
|
+
} as never;
|
|
172
|
+
const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, async () => null);
|
|
173
|
+
const build = (seeds: string[], knownEntries: Set<string>) =>
|
|
174
|
+
new RouteClientBuilder({
|
|
175
|
+
app,
|
|
176
|
+
seeds,
|
|
177
|
+
graphSeeds: [pageA, pageB],
|
|
178
|
+
knownEntries,
|
|
179
|
+
discovery,
|
|
180
|
+
artifact: {} as never,
|
|
181
|
+
}).build();
|
|
182
|
+
|
|
183
|
+
const first = await build([pageA], new Set());
|
|
184
|
+
const second = await build([pageB], new Set(first.newEntries));
|
|
185
|
+
|
|
186
|
+
expect(first.newEntries).toEqual([entryA, entryB]);
|
|
187
|
+
expect(first.discoveredEntries).toEqual([entryA]);
|
|
188
|
+
expect(first.clientDeps).not.toContain(entryB);
|
|
189
|
+
expect(Object.keys(first.clientDepsByEntry ?? {}).sort()).toEqual([entryA, entryB]);
|
|
190
|
+
expect(second.newEntries).toEqual([]);
|
|
191
|
+
expect(second.discoveredEntries).toEqual([entryB]);
|
|
192
|
+
expect(Object.keys(second.manifestDelta)).toEqual([]);
|
|
193
|
+
const clientOut = path.join(appDir, ".akan/artifact/client");
|
|
194
|
+
const outputs = [...new Bun.Glob("**/*.js").scanSync(clientOut)];
|
|
195
|
+
const carriers = await Promise.all(
|
|
196
|
+
outputs.map(async (file) => (await Bun.file(path.join(clientOut, file)).text()).includes("shared-module-marker")),
|
|
197
|
+
);
|
|
198
|
+
expect(carriers.filter(Boolean)).toHaveLength(1);
|
|
199
|
+
});
|
|
200
|
+
|
|
150
201
|
test("discovers client entries from installed akanjs package sources", async () => {
|
|
151
202
|
const root = await makeTempRoot();
|
|
152
203
|
const seed = path.join(root, "apps/demo/page/_index.tsx");
|
|
@@ -135,7 +135,7 @@ export class CssImportResolver {
|
|
|
135
135
|
|
|
136
136
|
async #firstExisting(basePath: string): Promise<string | null> {
|
|
137
137
|
for (const suffix of CSS_IMPORT_EXTS) {
|
|
138
|
-
const candidate = `${basePath}${suffix}
|
|
138
|
+
const candidate = path.normalize(`${basePath}${suffix}`);
|
|
139
139
|
if (await Bun.file(candidate).exists()) return candidate;
|
|
140
140
|
}
|
|
141
141
|
return null;
|
|
@@ -170,7 +170,7 @@ describe("FontOptimizer discovery", () => {
|
|
|
170
170
|
expect(result.fonts).toEqual([]);
|
|
171
171
|
expect(result.files).toEqual([]);
|
|
172
172
|
expect(warnings).toHaveLength(1);
|
|
173
|
-
expect(warnings[0]).toContain("page
|
|
173
|
+
expect(warnings[0]).toContain(path.join("page", "_layout.tsx"));
|
|
174
174
|
});
|
|
175
175
|
|
|
176
176
|
test("warns about an entry it cannot read and keeps the ones it can", async () => {
|
|
@@ -333,13 +333,14 @@ describe("RoutesManifestArtifactSerializer", () => {
|
|
|
333
333
|
describe("HmrChangeClassifier", () => {
|
|
334
334
|
test("classifies code, css, config, and ignored files", () => {
|
|
335
335
|
const classifier = new HmrChangeClassifier();
|
|
336
|
-
|
|
337
|
-
expect(classifier.classify("
|
|
338
|
-
expect(classifier.classify("/
|
|
339
|
-
expect(classifier.classify("
|
|
340
|
-
expect(classifier.classify(
|
|
341
|
-
expect(classifier.classify(
|
|
342
|
-
expect(classifier.classify("/
|
|
336
|
+
const app = path.resolve("/repo/apps/demo");
|
|
337
|
+
expect(classifier.classify(path.join(app, "page/_index.tsx"))).toBe("code");
|
|
338
|
+
expect(classifier.classify(path.join(app, "page/styles.css"))).toBe("css");
|
|
339
|
+
expect(classifier.classify(path.join(app, "akan.config.ts"))).toBe("config");
|
|
340
|
+
expect(classifier.classify(path.join(app, ".DS_Store"))).toBe("ignore");
|
|
341
|
+
expect(classifier.classify(path.join(app, "node_modules/pkg/index.ts"))).toBe("ignore");
|
|
342
|
+
expect(classifier.classify(path.join(app, ".akan/generated/page.tsx"))).toBe("ignore");
|
|
343
|
+
expect(classifier.classify(path.join(app, "public/logo.png"))).toBe("ignore");
|
|
343
344
|
});
|
|
344
345
|
});
|
|
345
346
|
|
|
@@ -394,16 +395,16 @@ describe("DevGeneratedIndexSync", () => {
|
|
|
394
395
|
|
|
395
396
|
describe("DevChangePlanner", () => {
|
|
396
397
|
test("classifies server, client, shared, and generated barrel changes", () => {
|
|
397
|
-
const root = "/repo";
|
|
398
|
+
const root = path.resolve("/repo");
|
|
398
399
|
const planner = new DevChangePlanner({ workspaceRoot: root });
|
|
399
|
-
const generatedIndex =
|
|
400
|
+
const generatedIndex = path.join(root, "libs/shared/common/index.ts");
|
|
400
401
|
const plan = planner.plan({
|
|
401
402
|
generation: 7,
|
|
402
403
|
files: [
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
404
|
+
path.join(root, "libs/shared/lib/admin/admin.service.ts"),
|
|
405
|
+
path.join(root, "libs/shared/lib/admin/Admin.Template.tsx"),
|
|
406
|
+
path.join(root, "libs/shared/lib/admin/admin.constant.ts"),
|
|
407
|
+
path.join(root, "libs/shared/common/foo.ts"),
|
|
407
408
|
],
|
|
408
409
|
kinds: ["code"],
|
|
409
410
|
generatedFiles: [generatedIndex],
|
|
@@ -429,21 +430,22 @@ describe("DevChangePlanner", () => {
|
|
|
429
430
|
});
|
|
430
431
|
|
|
431
432
|
test("recycles builder for macro-backed dictionary and signal metadata changes", () => {
|
|
432
|
-
const root = "/repo";
|
|
433
|
+
const root = path.resolve("/repo");
|
|
433
434
|
const planner = new DevChangePlanner({ workspaceRoot: root });
|
|
435
|
+
const dictionaryFile = path.join(root, "apps/demo/lib/_demo/demo.dictionary.ts");
|
|
434
436
|
const dictionaryPlan = planner.plan({
|
|
435
437
|
generation: 3,
|
|
436
|
-
files: [
|
|
438
|
+
files: [dictionaryFile],
|
|
437
439
|
kinds: ["code"],
|
|
438
440
|
});
|
|
439
441
|
const signalPlan = planner.plan({
|
|
440
442
|
generation: 4,
|
|
441
|
-
files: [
|
|
443
|
+
files: [path.join(root, "libs/shared/lib/admin/admin.signal.ts")],
|
|
442
444
|
kinds: ["code"],
|
|
443
445
|
});
|
|
444
446
|
|
|
445
447
|
expect(dictionaryPlan.actions).toEqual(["rebuild-client", "restart-backend", "restart-builder"]);
|
|
446
|
-
expect(dictionaryPlan.reasonByFile[
|
|
448
|
+
expect(dictionaryPlan.reasonByFile[dictionaryFile]).toContain("runtime-metadata");
|
|
447
449
|
expect(signalPlan.actions).toContain("restart-builder");
|
|
448
450
|
});
|
|
449
451
|
});
|
|
@@ -161,7 +161,7 @@ describe("HmrWatcher", () => {
|
|
|
161
161
|
TEST_TIMEOUT_MS,
|
|
162
162
|
);
|
|
163
163
|
|
|
164
|
-
test.skipIf(process.getuid?.() === 0)(
|
|
164
|
+
test.skipIf(process.getuid?.() === 0 || process.platform === "win32")(
|
|
165
165
|
"warns at startup when part of the tree cannot be read",
|
|
166
166
|
async () => {
|
|
167
167
|
const root = await makeRoot();
|
|
@@ -28,6 +28,15 @@ const SSR_CLIENT_ALIAS_EXTERNALS = [
|
|
|
28
28
|
export interface BuildRouteClientOptions {
|
|
29
29
|
app: App;
|
|
30
30
|
seeds: string[];
|
|
31
|
+
/**
|
|
32
|
+
* Seeds of every route, so a build that has to run at all bundles the whole app's client entries.
|
|
33
|
+
*
|
|
34
|
+
* A page loads entries first built for other routes, and entries from two `Bun.build` runs each carry
|
|
35
|
+
* their own copy of every app and lib module — one page evaluated a lib's module-scope registration
|
|
36
|
+
* twice. The whole app costs about what one route did, since every entry already imports the client
|
|
37
|
+
* barrel, and content-hashed chunk names keep unchanged chunks at the URLs the browser already holds.
|
|
38
|
+
*/
|
|
39
|
+
graphSeeds?: string[];
|
|
31
40
|
artifact: BaseBuildArtifact;
|
|
32
41
|
knownEntries?: Set<string>;
|
|
33
42
|
routeId?: string;
|
|
@@ -39,13 +48,13 @@ export interface BuildRouteClientOptions {
|
|
|
39
48
|
* `AllRoutesBuilder` passes every route's entries at once so they share one `Bun.build`. Chunk
|
|
40
49
|
* splitting is scoped to a single invocation, so a dependency reachable from entries spread across
|
|
41
50
|
* several invocations is emitted once per invocation — mermaid landed in `apps/akan` four times that
|
|
42
|
-
* way. Dev
|
|
51
|
+
* way. Dev passes `graphSeeds` instead, for the same reason.
|
|
43
52
|
*/
|
|
44
53
|
entries?: string[];
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
export interface BuildRouteClientResult {
|
|
48
|
-
/** Newly-emitted manifest rows keyed by `${absEntry}#${exportName}
|
|
57
|
+
/** Newly-emitted manifest rows keyed by `${absEntry}#${exportName}`, for every entry this build bundled. */
|
|
49
58
|
manifestDelta: ClientManifest;
|
|
50
59
|
/** Newly-emitted ssrManifest rows keyed by entry URL. */
|
|
51
60
|
ssrManifestDelta: SsrManifest;
|
|
@@ -53,7 +62,7 @@ export interface BuildRouteClientResult {
|
|
|
53
62
|
newEntries: string[];
|
|
54
63
|
/** Absolute paths of all `"use client"` leaves discovered from this route's seeds. */
|
|
55
64
|
discoveredEntries?: string[];
|
|
56
|
-
/** Absolute source files included in the browser bundles for
|
|
65
|
+
/** Absolute source files included in the browser bundles for this route's bundled entries. */
|
|
57
66
|
clientDeps: string[];
|
|
58
67
|
/** Absolute source files included in the browser bundle, grouped by original client entry. */
|
|
59
68
|
clientDepsByEntry?: Record<string, string[]>;
|
|
@@ -67,6 +76,7 @@ interface BootstrapEntries {
|
|
|
67
76
|
export class RouteClientBuilder {
|
|
68
77
|
#app: App;
|
|
69
78
|
#seeds: string[];
|
|
79
|
+
#graphSeeds?: string[];
|
|
70
80
|
#knownEntries: Set<string>;
|
|
71
81
|
#command: "build" | "start";
|
|
72
82
|
#discovery?: ClientEntryDiscovery;
|
|
@@ -75,6 +85,7 @@ export class RouteClientBuilder {
|
|
|
75
85
|
constructor(options: BuildRouteClientOptions) {
|
|
76
86
|
this.#app = options.app;
|
|
77
87
|
this.#seeds = options.seeds;
|
|
88
|
+
this.#graphSeeds = options.graphSeeds;
|
|
78
89
|
this.#knownEntries = options.knownEntries ?? new Set<string>();
|
|
79
90
|
this.#command = options.command ?? "start";
|
|
80
91
|
this.#discovery = options.discovery;
|
|
@@ -82,17 +93,18 @@ export class RouteClientBuilder {
|
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
async build(): Promise<BuildRouteClientResult> {
|
|
85
|
-
const discovered =
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
const discovered = this.#entries ?? (await (await this.#getDiscovery()).discover(this.#seeds));
|
|
97
|
+
if (discovered.every((e) => this.#knownEntries.has(e))) return this.#emptyResult(discovered);
|
|
98
|
+
const entries = this.#graphSeeds
|
|
99
|
+
? [...new Set([...discovered, ...(await (await this.#getDiscovery()).discover(this.#graphSeeds))])].sort()
|
|
100
|
+
: discovered.filter((e) => !this.#knownEntries.has(e));
|
|
90
101
|
|
|
91
102
|
const bootstrapEntries = await this.#createBootstrapEntries(entries);
|
|
92
103
|
const browserBundle = await this.#buildBrowserBundle(bootstrapEntries);
|
|
93
104
|
const ssrBundle = await this.#buildSsrBundle(bootstrapEntries);
|
|
94
105
|
|
|
95
106
|
const acceptedEntries = new Set(entries);
|
|
107
|
+
const routeEntries = new Set(discovered);
|
|
96
108
|
const manifestDelta: ClientManifest = {};
|
|
97
109
|
const ssrModuleMap: SsrManifest["moduleMap"] = {};
|
|
98
110
|
const clientDeps = new Set<string>();
|
|
@@ -122,19 +134,24 @@ export class RouteClientBuilder {
|
|
|
122
134
|
for (const dep of browserBundle.entryDepsByAbsPath.get(buildEntry) ?? []) deps.add(path.resolve(dep));
|
|
123
135
|
const sortedDeps = [...deps].sort();
|
|
124
136
|
clientDepsByEntry[originalEntry] = sortedDeps;
|
|
125
|
-
for (const dep of sortedDeps) clientDeps.add(dep);
|
|
137
|
+
if (routeEntries.has(originalEntry)) for (const dep of sortedDeps) clientDeps.add(dep);
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
return {
|
|
129
141
|
manifestDelta,
|
|
130
142
|
ssrManifestDelta: { moduleLoading: null, moduleMap: ssrModuleMap },
|
|
131
|
-
newEntries: entries,
|
|
143
|
+
newEntries: entries.filter((e) => !this.#knownEntries.has(e)),
|
|
132
144
|
discoveredEntries: discovered,
|
|
133
145
|
clientDeps: [...clientDeps].sort(),
|
|
134
146
|
clientDepsByEntry,
|
|
135
147
|
};
|
|
136
148
|
}
|
|
137
149
|
|
|
150
|
+
async #getDiscovery(): Promise<ClientEntryDiscovery> {
|
|
151
|
+
this.#discovery ??= await GraphClientEntryDiscovery.create(this.#app);
|
|
152
|
+
return this.#discovery;
|
|
153
|
+
}
|
|
154
|
+
|
|
138
155
|
#emptyResult(discoveredEntries: string[] = []): BuildRouteClientResult {
|
|
139
156
|
return {
|
|
140
157
|
manifestDelta: {},
|
|
@@ -249,11 +249,12 @@ describe("SourceMtimeIndex", () => {
|
|
|
249
249
|
describe("a directory it cannot read", () => {
|
|
250
250
|
/**
|
|
251
251
|
* `chmod 000` does not stop root, so these would assert the opposite of what they mean when the suite
|
|
252
|
-
* runs as root (CI containers commonly do).
|
|
252
|
+
* runs as root (CI containers commonly do). On Windows it only sets the read-only attribute, which leaves
|
|
253
|
+
* a directory listable.
|
|
253
254
|
*/
|
|
254
|
-
const
|
|
255
|
+
const cannotLock = process.getuid?.() === 0 || process.platform === "win32";
|
|
255
256
|
|
|
256
|
-
test.skipIf(
|
|
257
|
+
test.skipIf(cannotLock)("is reported as a gap instead of silently skipped", async () => {
|
|
257
258
|
const root = await makeRoot();
|
|
258
259
|
await seed(root, "open/a.ts");
|
|
259
260
|
const hidden = await seed(root, "locked/b.ts");
|
|
@@ -269,7 +270,7 @@ describe("SourceMtimeIndex", () => {
|
|
|
269
270
|
expect(index.coverageGaps).toEqual([{ path: path.dirname(hidden), code: "EACCES" }]);
|
|
270
271
|
});
|
|
271
272
|
|
|
272
|
-
test.skipIf(
|
|
273
|
+
test.skipIf(cannotLock)("becomes visible again once it is readable, and clears the gap", async () => {
|
|
273
274
|
const root = await makeRoot();
|
|
274
275
|
const hidden = await seed(root, "locked/b.ts");
|
|
275
276
|
const locked = path.dirname(hidden);
|
|
@@ -287,7 +288,7 @@ describe("SourceMtimeIndex", () => {
|
|
|
287
288
|
expect(await index.collectChanges()).toEqual([hidden]);
|
|
288
289
|
});
|
|
289
290
|
|
|
290
|
-
test.skipIf(
|
|
291
|
+
test.skipIf(cannotLock)("does not report the files under it as deleted while it is unreadable", async () => {
|
|
291
292
|
const root = await makeRoot();
|
|
292
293
|
const abs = await seed(root, "locked/b.ts");
|
|
293
294
|
const locked = path.dirname(abs);
|
package/getDirname.test.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
3
4
|
import { getDirname } from "./getDirname";
|
|
4
5
|
|
|
5
6
|
describe("getDirname", () => {
|
|
6
7
|
test("converts file URLs to filesystem paths", () => {
|
|
7
|
-
const
|
|
8
|
+
const filePath = path.resolve("/tmp/akan workspace/index.ts");
|
|
9
|
+
const dirname = getDirname(pathToFileURL(filePath).href);
|
|
8
10
|
|
|
9
|
-
expect(dirname).toBe(path.
|
|
11
|
+
expect(dirname).toBe(path.dirname(filePath));
|
|
10
12
|
});
|
|
11
13
|
|
|
12
14
|
test("keeps Windows drive paths valid when running on Windows", () => {
|