@akanjs/devkit 3.2.0-rc.3 → 3.2.0-rc.6
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 +80 -11
- package/akanApp/akanApp.host.test.ts +51 -0
- package/akanApp/akanApp.host.ts +3 -3
- package/akanContext.ts +2 -3
- package/apiReport/apiDeclarationPrinter.ts +120 -0
- package/apiReport/apiReport.test.ts +483 -0
- package/apiReport/apiReport.ts +232 -0
- package/apiReport/apiReportDiff.ts +126 -0
- package/apiReport/apiReportPolicy.ts +64 -0
- package/apiReport/apiTargets.ts +311 -0
- package/apiReport/changesetBump.ts +19 -0
- package/applicationBuildReporter.test.ts +21 -0
- package/applicationBuildReporter.ts +8 -0
- package/applicationBuildRunner.test.ts +67 -0
- package/applicationBuildRunner.ts +6 -1
- package/artifact/implicitRootLayout.ts +12 -1
- package/codeAgent/skills/akan-debug/SKILL.md +6 -2
- package/codeAgent/skills/akan-transport/SKILL.md +2 -0
- package/commandDecorators/argMeta.ts +4 -0
- package/commandDecorators/command.ts +51 -33
- package/commandDecorators/commandDecorators.test.ts +74 -9
- package/frontendBuild/allRoutesBuilder.test.ts +16 -4
- package/frontendBuild/allRoutesBuilder.ts +24 -6
- package/frontendBuild/bundleDefine.ts +2 -0
- package/frontendBuild/clientBuildTypes.ts +5 -2
- package/frontendBuild/clientEntryDiscovery.test.ts +28 -0
- package/frontendBuild/clientEntryDiscovery.ts +25 -6
- package/frontendBuild/nativeCore.test.ts +148 -0
- package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
- package/frontendBuild/vendorImportScanner.test.ts +24 -3
- package/frontendBuild/vendorImportScanner.ts +25 -10
- package/incrementalBuilder/incrementalBuilder.host.test.ts +5 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +2 -1
- package/lint/no-web-only-api-outside-webkit.grit +3 -3
- package/{constantFieldNames.test.ts → moduleFieldNames.test.ts} +24 -2
- package/moduleFieldNames.ts +25 -0
- package/package.json +3 -3
- package/preview/PreviewView.ts +32 -13
- package/preview/previewView.test.ts +33 -3
- package/routeJsBudget.test.ts +47 -11
- package/routeJsBudget.ts +88 -25
- package/workflow/plainName.ts +16 -3
- package/constantFieldNames.ts +0 -18
|
@@ -9,7 +9,15 @@ import { Logger } from "akanjs/common";
|
|
|
9
9
|
import chalk from "chalk";
|
|
10
10
|
import { type Command, program } from "commander";
|
|
11
11
|
import { AppSelectionMemory } from "../appSelectionMemory";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
AppExecutor,
|
|
14
|
+
Executor,
|
|
15
|
+
LibExecutor,
|
|
16
|
+
ModuleExecutor,
|
|
17
|
+
PkgExecutor,
|
|
18
|
+
type SysExecutor,
|
|
19
|
+
WorkspaceExecutor,
|
|
20
|
+
} from "../executors";
|
|
13
21
|
// Never the root barrel: it drags ink, ssh2 and the cloud stack into every command process.
|
|
14
22
|
import { FileSys, getDirname } from "../fileSys";
|
|
15
23
|
import type { PackageJson } from "../types";
|
|
@@ -311,6 +319,17 @@ export const getInternalArgumentValue = async (
|
|
|
311
319
|
if (destructive) throw refusalOf(problemOf(kind), "this command deletes files and never picks its target", refusal);
|
|
312
320
|
assertPromptable(problemOf(kind), refusal);
|
|
313
321
|
};
|
|
322
|
+
const takeOnly = <Target>(kind: string, names: readonly string[], take: (name: string) => Target) => {
|
|
323
|
+
const [only] = names;
|
|
324
|
+
if (value || destructive || process.stdin.isTTY || names.length !== 1 || !only) return null;
|
|
325
|
+
const how = rerun ? `: ${rerun(only)}` : "";
|
|
326
|
+
Logger.rawLog(
|
|
327
|
+
`No ${kind} named, so this runs on '${only}', the only one in this workspace${how}`,
|
|
328
|
+
undefined,
|
|
329
|
+
"error",
|
|
330
|
+
);
|
|
331
|
+
return take(only);
|
|
332
|
+
};
|
|
314
333
|
if (sysType === "sys" || sysType === "exec") {
|
|
315
334
|
const pkgChoices = sysType === "exec" ? pkgNames : [];
|
|
316
335
|
const execOf = (name: string) => {
|
|
@@ -326,17 +345,8 @@ export const getInternalArgumentValue = async (
|
|
|
326
345
|
const [kind, placeholder] =
|
|
327
346
|
sysType === "exec" ? ["app, lib or pkg", "<app-lib-or-pkg>"] : ["app or lib", "<app-or-lib>"];
|
|
328
347
|
const choices = [...appNames, ...libNames, ...pkgChoices];
|
|
329
|
-
const only = choices
|
|
330
|
-
|
|
331
|
-
if (!value && !destructive && only && !process.stdin.isTTY) {
|
|
332
|
-
const how = rerun ? `: ${rerun(only.name)}` : "";
|
|
333
|
-
Logger.rawLog(
|
|
334
|
-
`No ${kind} named, so this runs on '${only.name}', the only one in this workspace${how}`,
|
|
335
|
-
undefined,
|
|
336
|
-
"error",
|
|
337
|
-
);
|
|
338
|
-
return only;
|
|
339
|
-
}
|
|
348
|
+
const only = takeOnly(kind, choices, execOf);
|
|
349
|
+
if (only) return only;
|
|
340
350
|
assertAskable(kind, choices, placeholder);
|
|
341
351
|
const name = await select<string>({
|
|
342
352
|
message: sysType === "exec" ? `Select the App or Lib or Pkg name` : `Select the App or Lib name`,
|
|
@@ -353,40 +363,48 @@ export const getInternalArgumentValue = async (
|
|
|
353
363
|
return AppExecutor.from(workspace, appName);
|
|
354
364
|
} else if (sysType === "lib") {
|
|
355
365
|
if (value && libNames.includes(value)) return LibExecutor.from(workspace, value);
|
|
366
|
+
const only = takeOnly("lib", libNames, (name) => LibExecutor.from(workspace, name));
|
|
367
|
+
if (only) return only;
|
|
356
368
|
assertAskable("lib", libNames, "<lib>");
|
|
357
369
|
const libName = await select<string>({ message: `Select the ${sysType} name`, choices: libNames });
|
|
358
370
|
return LibExecutor.from(workspace, libName);
|
|
359
371
|
} else if (sysType === "pkg") {
|
|
360
372
|
const pkgs = await workspace.getPkgs();
|
|
361
373
|
if (value && pkgs.includes(value)) return PkgExecutor.from(workspace, value);
|
|
374
|
+
const only = takeOnly("pkg", pkgs, (name) => PkgExecutor.from(workspace, name));
|
|
375
|
+
if (only) return only;
|
|
362
376
|
assertAskable("pkg", pkgs, "<pkg>");
|
|
363
377
|
const pkgName = await select<string>({ message: `Select the ${sysType} name`, choices: pkgs });
|
|
364
378
|
return PkgExecutor.from(workspace, pkgName);
|
|
365
379
|
} else if (sysType === "module") {
|
|
366
|
-
if (value) {
|
|
367
|
-
const [sysName, moduleName] = value.split(":");
|
|
368
|
-
if (!sysName || !moduleName) throw new Error(`Invalid module name: ${value}`);
|
|
369
|
-
if (appNames.includes(sysName)) {
|
|
370
|
-
const app = AppExecutor.from(workspace, sysName);
|
|
371
|
-
const modules = await app.getModules();
|
|
372
|
-
if (modules.includes(moduleName)) return ModuleExecutor.from(app, moduleName);
|
|
373
|
-
else throw new Error(`Invalid module name: ${moduleName}`);
|
|
374
|
-
} else if (libNames.includes(sysName)) {
|
|
375
|
-
const lib = LibExecutor.from(workspace, sysName);
|
|
376
|
-
const modules = await lib.getModules();
|
|
377
|
-
if (modules.includes(moduleName)) return ModuleExecutor.from(lib, moduleName);
|
|
378
|
-
} else throw new Error(`Invalid system name: ${sysName}`);
|
|
379
|
-
}
|
|
380
380
|
const systems = [
|
|
381
381
|
...appNames.map((name) => AppExecutor.from(workspace, name)),
|
|
382
382
|
...libNames.map((name) => LibExecutor.from(workspace, name)),
|
|
383
383
|
];
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
384
|
+
const modulesOf = async (sys: SysExecutor) => [
|
|
385
|
+
...(await sys.getModules()),
|
|
386
|
+
...(argMeta.option?.services ? await sys.getServiceModules() : []),
|
|
387
|
+
];
|
|
388
|
+
const listModules = async () =>
|
|
389
|
+
(await Promise.all(systems.map(async (sys) => (await modulesOf(sys)).map((m) => `${sys.name}:${m}`)))).flat();
|
|
390
|
+
if (value) {
|
|
391
|
+
const [sysName, moduleName] = value.split(":");
|
|
392
|
+
if (!sysName || !moduleName) throw new Error(`Invalid module name: ${value}`);
|
|
393
|
+
const sys = systems.find((each) => each.name === sysName);
|
|
394
|
+
if (!sys) throw new Error(`Invalid system name: ${sysName}`);
|
|
395
|
+
if ((await modulesOf(sys)).includes(moduleName)) return ModuleExecutor.from(sys, moduleName);
|
|
396
|
+
throw refusalOf(problemOf("module"), `${sysName} has no such module`, {
|
|
397
|
+
choices: await listModules(),
|
|
398
|
+
...rerunRemedyOf(rerun, "<sys:module>"),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
const choices = process.stdin.isTTY && !destructive ? [] : await listModules();
|
|
402
|
+
const only = takeOnly("module", choices, (label) => {
|
|
403
|
+
const [sysName, moduleName = ""] = label.split(":");
|
|
404
|
+
const sys = systems.find((each) => each.name === sysName);
|
|
405
|
+
return sys ? ModuleExecutor.from(sys, moduleName) : null;
|
|
406
|
+
});
|
|
407
|
+
if (only) return only;
|
|
390
408
|
assertAskable("module", choices, "<sys:module>");
|
|
391
409
|
const { type, name } = await select<{ type: "app" | "lib"; name: string }>({
|
|
392
410
|
message: `select the App or Lib name`,
|
|
@@ -396,7 +414,7 @@ export const getInternalArgumentValue = async (
|
|
|
396
414
|
],
|
|
397
415
|
});
|
|
398
416
|
const executor = type === "app" ? AppExecutor.from(workspace, name) : LibExecutor.from(workspace, name);
|
|
399
|
-
const modules = await executor
|
|
417
|
+
const modules = await modulesOf(executor);
|
|
400
418
|
const moduleName = await select<string>({
|
|
401
419
|
message: `Select the module name`,
|
|
402
420
|
choices: modules.map((name) => ({ name: `${executor.name}:${name}`, value: name })),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "../executors";
|
|
3
|
+
import { AppExecutor, LibExecutor, ModuleExecutor, PkgExecutor, WorkspaceExecutor } from "../executors";
|
|
4
4
|
import { makeCliTempWorkspace, tempRoots, writeText } from "../testHelpers";
|
|
5
5
|
import {
|
|
6
6
|
App,
|
|
@@ -440,6 +440,7 @@ describe("enum arg type inference", () => {
|
|
|
440
440
|
|
|
441
441
|
describe("a Sys that defaults to the only app", () => {
|
|
442
442
|
const ownIsTTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
|
|
443
|
+
const ownCwd = process.cwd();
|
|
443
444
|
const asked: unknown[] = [];
|
|
444
445
|
let answer = "";
|
|
445
446
|
const setTTY = (isTTY: boolean | undefined) =>
|
|
@@ -455,6 +456,7 @@ describe("a Sys that defaults to the only app", () => {
|
|
|
455
456
|
});
|
|
456
457
|
afterEach(() => {
|
|
457
458
|
CommandContainer.clear();
|
|
459
|
+
process.chdir(ownCwd);
|
|
458
460
|
if (ownIsTTY) Object.defineProperty(process.stdin, "isTTY", ownIsTTY);
|
|
459
461
|
else Reflect.deleteProperty(process.stdin, "isTTY");
|
|
460
462
|
});
|
|
@@ -476,6 +478,21 @@ describe("a Sys that defaults to the only app", () => {
|
|
|
476
478
|
if (!meta) throw new Error(`no Sys meta on ${key}`);
|
|
477
479
|
return meta;
|
|
478
480
|
};
|
|
481
|
+
const track = tempRoots();
|
|
482
|
+
//? From the workspace root, as the CLI runs: getDirInModule stats its entries relative to the process directory.
|
|
483
|
+
const moduleWorkspace = async () => {
|
|
484
|
+
const { root, workspace } = track(await makeCliTempWorkspace());
|
|
485
|
+
process.chdir(root);
|
|
486
|
+
for (const [sys, name] of [
|
|
487
|
+
["apps/web", "card"],
|
|
488
|
+
["libs/util", "photo"],
|
|
489
|
+
] as const) {
|
|
490
|
+
await writeText(path.join(root, sys, "akan.config.ts"), "export default {};\n");
|
|
491
|
+
await writeText(path.join(root, sys, "lib", name, `${name}.constant.ts`), "export {};\n");
|
|
492
|
+
}
|
|
493
|
+
await writeText(path.join(root, "apps/web/lib/_billing/billing.service.ts"), "export {};\n");
|
|
494
|
+
return workspace;
|
|
495
|
+
};
|
|
479
496
|
|
|
480
497
|
test("the opted-in command takes a one-app workspace's app without asking, even on a piped stdin", async () => {
|
|
481
498
|
setTTY(undefined);
|
|
@@ -525,19 +542,24 @@ describe("a Sys that defaults to the only app", () => {
|
|
|
525
542
|
expect(asked).toEqual([]);
|
|
526
543
|
});
|
|
527
544
|
|
|
528
|
-
test("without a terminal, the only lib, pkg or module
|
|
545
|
+
test("without a terminal, a command that deletes nothing takes the only lib, pkg or module; a terminal is asked", async () => {
|
|
529
546
|
setTTY(undefined);
|
|
530
547
|
const workspace = workspaceWith([]);
|
|
531
548
|
workspace.getExecs = async () => [[], ["util"], []];
|
|
532
549
|
workspace.getPkgs = async () => ["tools"];
|
|
533
|
-
|
|
534
|
-
await
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
550
|
+
workspace.getDirInModule = async (basePath) => (basePath === "libs/util/lib" ? ["photo"] : []);
|
|
551
|
+
const lib = await getInternalArgumentValue({ key: "", idx: 0, type: "Lib" }, undefined, workspace);
|
|
552
|
+
expect(lib instanceof LibExecutor ? lib.name : null).toBe("util");
|
|
553
|
+
const pkg = await getInternalArgumentValue({ key: "", idx: 0, type: "Pkg" }, undefined, workspace);
|
|
554
|
+
expect(pkg instanceof PkgExecutor ? pkg.name : null).toBe("tools");
|
|
555
|
+
const mod = await getInternalArgumentValue({ key: "", idx: 0, type: "Module" }, undefined, workspace);
|
|
556
|
+
expect(mod instanceof ModuleExecutor ? `${mod.sys.name}:${mod.name}` : null).toBe("util:photo");
|
|
540
557
|
expect(asked).toEqual([]);
|
|
558
|
+
|
|
559
|
+
setTTY(true);
|
|
560
|
+
answer = "util";
|
|
561
|
+
await getInternalArgumentValue({ key: "", idx: 0, type: "Lib" }, undefined, workspace);
|
|
562
|
+
expect(asked).toEqual([["util"]]);
|
|
541
563
|
});
|
|
542
564
|
|
|
543
565
|
test("without a terminal, a named target the workspace lacks is refused, not swapped for the only one", async () => {
|
|
@@ -608,6 +630,49 @@ describe("a Sys that defaults to the only app", () => {
|
|
|
608
630
|
expect(taken instanceof AppExecutor ? taken.name : null).toBe("web");
|
|
609
631
|
expect(asked).toEqual([]);
|
|
610
632
|
});
|
|
633
|
+
|
|
634
|
+
test("a module an app or a lib lacks is refused with the modules there are, in a terminal too", async () => {
|
|
635
|
+
setTTY(true);
|
|
636
|
+
answer = "util";
|
|
637
|
+
const workspace = await moduleWorkspace();
|
|
638
|
+
for (const value of ["util:nope", "web:nope"]) {
|
|
639
|
+
const sysName = value.split(":")[0];
|
|
640
|
+
await expect(
|
|
641
|
+
getInternalArgumentValue(
|
|
642
|
+
{ key: "", idx: 0, type: "Module" },
|
|
643
|
+
value,
|
|
644
|
+
workspace,
|
|
645
|
+
rerunCommandOf(["create-view", value], [value], 0),
|
|
646
|
+
),
|
|
647
|
+
).rejects.toThrow(
|
|
648
|
+
`Unknown module '${value}': ${sysName} has no such module, so rerun it as \`akan create-view <sys:module>\` (choices: web:card, util:photo).`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
expect(asked).toEqual([]);
|
|
652
|
+
const photo = await getInternalArgumentValue({ key: "", idx: 0, type: "Module" }, "util:photo", workspace);
|
|
653
|
+
expect(photo instanceof ModuleExecutor ? `${photo.sys.name}:${photo.name}` : null).toBe("util:photo");
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("a service module is taken by its exact name where the target opts in, as remove-module does", async () => {
|
|
657
|
+
setTTY(true);
|
|
658
|
+
const workspace = await moduleWorkspace();
|
|
659
|
+
const services = { key: "", idx: 0, type: "Module", option: { services: true } } as const;
|
|
660
|
+
const billing = await getInternalArgumentValue(services, "web:_billing", workspace, undefined, {
|
|
661
|
+
destructive: true,
|
|
662
|
+
});
|
|
663
|
+
expect(billing instanceof ModuleExecutor ? `${billing.sys.name}:${billing.name}` : null).toBe("web:_billing");
|
|
664
|
+
await expect(
|
|
665
|
+
getInternalArgumentValue(services, undefined, workspace, rerunCommandOf(["remove-module"], [undefined], 0), {
|
|
666
|
+
destructive: true,
|
|
667
|
+
}),
|
|
668
|
+
).rejects.toThrow(
|
|
669
|
+
"so rerun it as `akan remove-module <sys:module>` (choices: web:card, web:_billing, util:photo).",
|
|
670
|
+
);
|
|
671
|
+
await expect(
|
|
672
|
+
getInternalArgumentValue({ key: "", idx: 0, type: "Module" }, "web:_billing", workspace),
|
|
673
|
+
).rejects.toThrow("Unknown module 'web:_billing': web has no such module");
|
|
674
|
+
expect(asked).toEqual([]);
|
|
675
|
+
});
|
|
611
676
|
});
|
|
612
677
|
|
|
613
678
|
describe("the line a refused target is rerun with", () => {
|
|
@@ -32,14 +32,18 @@ const writeApp = async () => {
|
|
|
32
32
|
);
|
|
33
33
|
await write(
|
|
34
34
|
path.join(appDir, "page/memo.tsx"),
|
|
35
|
-
'import { Memo } from "../ui/Memo";\nexport default function Page() {\n return
|
|
35
|
+
'import { System } from "akanjs/ui/System";\nimport { Memo } from "../ui/Memo";\nexport default function Page() {\n return <><Memo /><System.ThemeToggle /></>;\n}\n',
|
|
36
|
+
);
|
|
37
|
+
await write(
|
|
38
|
+
path.join(appDir, "page/shell.tsx"),
|
|
39
|
+
'import { CSR } from "akanjs/ui/System/CSR";\nexport default function Page() {\n return <CSR />;\n}\n',
|
|
36
40
|
);
|
|
37
41
|
const app = {
|
|
38
42
|
name: "demo",
|
|
39
43
|
cwdPath: appDir,
|
|
40
44
|
dist: { cwdPath: path.join(root, "dist/apps/demo") },
|
|
41
45
|
workspace: { workspaceRoot: root },
|
|
42
|
-
getPageKeys: async () => ["./_index.tsx", "./memo.tsx"],
|
|
46
|
+
getPageKeys: async () => ["./_index.tsx", "./memo.tsx", "./shell.tsx"],
|
|
43
47
|
getConfig: async () => ({ basePaths: [], optimizeImports: [], barrelImports: [] }),
|
|
44
48
|
getTsConfig: async () => ({ compilerOptions: { paths } }),
|
|
45
49
|
getPublicEnv: () => ({}),
|
|
@@ -51,12 +55,12 @@ const writeApp = async () => {
|
|
|
51
55
|
};
|
|
52
56
|
|
|
53
57
|
describe("AllRoutesBuilder", () => {
|
|
54
|
-
test("writes the route manifest, then the preload table once the runtime chunks are known, and each route's modules", async () => {
|
|
58
|
+
test("writes the route manifest, then the preload table once the runtime chunks are known, and each route's modules without System's CSR and MCP App providers unless the page imports one", async () => {
|
|
55
59
|
const app = await writeApp();
|
|
56
60
|
const routes = new AllRoutesBuilder(app as never, "build");
|
|
57
61
|
const { manifest, manifestPath } = await routes.build();
|
|
58
62
|
|
|
59
|
-
expect(new Set(manifest.routeIds)).toEqual(new Set(["/:lang", "/:lang/memo"]));
|
|
63
|
+
expect(new Set(manifest.routeIds)).toEqual(new Set(["/:lang", "/:lang/memo", "/:lang/shell"]));
|
|
60
64
|
expect(await Bun.file(manifestPath).json()).not.toHaveProperty("modulePreloads");
|
|
61
65
|
|
|
62
66
|
const runtimeChunks = { runtime: ["/_akan/client/rsc.js"], vendors: { react: ["/_akan/client/react.js"] } };
|
|
@@ -70,8 +74,14 @@ describe("AllRoutesBuilder", () => {
|
|
|
70
74
|
const [home] = chunksOf("apps/demo/ui/Home.tsx#Home");
|
|
71
75
|
const [memo] = chunksOf("apps/demo/ui/Memo.tsx#Memo");
|
|
72
76
|
const shell = chunksOf("akanjs/ui/System/Client.tsx#Client");
|
|
77
|
+
const [csrShell] = chunksOf("akanjs/ui/System/CSR.tsx#CSR");
|
|
78
|
+
const [mcpAppShell] = chunksOf("akanjs/ui/System/App.tsx#AppProvider");
|
|
79
|
+
const [themeToggle] = chunksOf("akanjs/ui/System/ThemeToggle.tsx#ThemeToggle");
|
|
73
80
|
const modules = preloads.modulesByRoute();
|
|
74
81
|
expect(shell.length).toBeGreaterThan(0);
|
|
82
|
+
expect([csrShell, mcpAppShell, themeToggle]).not.toContain(undefined);
|
|
83
|
+
expect(modules["/:lang/memo"]).toContain(themeToggle);
|
|
84
|
+
expect(modules["/:lang/shell"]).toContain(csrShell);
|
|
75
85
|
for (const [routeId, own, other] of [
|
|
76
86
|
["/:lang", home, memo],
|
|
77
87
|
["/:lang/memo", memo, home],
|
|
@@ -79,6 +89,8 @@ describe("AllRoutesBuilder", () => {
|
|
|
79
89
|
expect(modules[routeId]?.slice(0, 2)).toEqual(["/_akan/client/rsc.js", "/_akan/client/react.js"]);
|
|
80
90
|
expect(modules[routeId]).toEqual(expect.arrayContaining([own, ...shell]));
|
|
81
91
|
expect(modules[routeId]).not.toContain(other);
|
|
92
|
+
expect(modules[routeId]).not.toContain(csrShell);
|
|
93
|
+
expect(modules[routeId]).not.toContain(mcpAppShell);
|
|
82
94
|
}
|
|
83
95
|
}, 30_000);
|
|
84
96
|
});
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import type { RoutesManifest } from "akanjs/server";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
isImplicitRootLayoutKey,
|
|
5
|
+
type PageEntry,
|
|
6
|
+
ROOT_LAYOUT_SYSTEM,
|
|
7
|
+
resolveSsrPageEntriesForApp,
|
|
8
|
+
} from "../artifact/implicitRootLayout";
|
|
4
9
|
import { computeRouteSeedIndex, type RouteSeedIndex } from "../artifact/routeSeedIndex";
|
|
5
10
|
import type { App } from "../commandDecorators";
|
|
6
11
|
import type { ClientEntryDiscovery } from "./clientBuildTypes";
|
|
@@ -26,7 +31,7 @@ export class AllRoutesBuilder {
|
|
|
26
31
|
};
|
|
27
32
|
#knownSet = new Set<string>();
|
|
28
33
|
#routeIds: string[] = [];
|
|
29
|
-
#
|
|
34
|
+
#ssrEntriesByRoute = new Map<string, string[]>();
|
|
30
35
|
#externalsByChunkUrl: Record<string, string[]> = {};
|
|
31
36
|
#discovery: ClientEntryDiscovery | null = null;
|
|
32
37
|
|
|
@@ -41,15 +46,17 @@ export class AllRoutesBuilder {
|
|
|
41
46
|
const pageEntries = await resolveSsrPageEntriesForApp(this.#app, pageKeys);
|
|
42
47
|
const seedIndex = computeRouteSeedIndex(pageEntries);
|
|
43
48
|
this.#app.verbose(`[build-all] discovered ${seedIndex.entries.length} routes`);
|
|
44
|
-
|
|
49
|
+
const discovery = await GraphClientEntryDiscovery.create(this.#app);
|
|
50
|
+
this.#discovery = discovery;
|
|
51
|
+
const importsAs = await this.#ssrImportsAs(discovery, pageEntries);
|
|
45
52
|
|
|
46
53
|
// Every route's entries go into one Bun.build: chunk splitting only dedupes within a single build.
|
|
47
54
|
const allEntries = new Set<string>();
|
|
48
55
|
for (const entry of seedIndex.entries) {
|
|
49
56
|
const seeds = Array.from(new Set([...seedIndex.globalLayoutFiles, ...entry.seeds]));
|
|
50
|
-
const routeEntries = await
|
|
57
|
+
const routeEntries = await discovery.discover(seeds);
|
|
51
58
|
for (const discovered of routeEntries) allEntries.add(discovered);
|
|
52
|
-
this.#
|
|
59
|
+
this.#ssrEntriesByRoute.set(entry.routeId, await discovery.discover(seeds, { importsAs }));
|
|
53
60
|
this.#routeIds.push(entry.routeId);
|
|
54
61
|
}
|
|
55
62
|
this.#app.verbose(`[build-all] ${allEntries.size} client entries across ${this.#routeIds.length} routes`);
|
|
@@ -71,10 +78,21 @@ export class AllRoutesBuilder {
|
|
|
71
78
|
return { manifest, manifestPath, seedIndex };
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
async #ssrImportsAs(discovery: GraphClientEntryDiscovery, pageEntries: PageEntry[]) {
|
|
82
|
+
const { specifier, ssrShell, importsNoSsrPageRenders } = ROOT_LAYOUT_SYSTEM;
|
|
83
|
+
const importsAs = new Map<string, ReadonlyMap<string, string | null>>();
|
|
84
|
+
const toShell = new Map([[specifier, ssrShell]]);
|
|
85
|
+
for (const { key, moduleAbsPath } of pageEntries)
|
|
86
|
+
if (isImplicitRootLayoutKey(key)) importsAs.set(path.resolve(moduleAbsPath), toShell);
|
|
87
|
+
const system = await discovery.resolveImport(specifier, this.#app.cwdPath);
|
|
88
|
+
if (system) importsAs.set(system, new Map(importsNoSsrPageRenders.map((spec) => [spec, null])));
|
|
89
|
+
return importsAs;
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
async writeModulePreloads(runtimeChunks: RuntimeChunks): Promise<RouteModulePreloads> {
|
|
75
93
|
const preloads = new RouteModulePreloads({
|
|
76
94
|
runtimeChunks,
|
|
77
|
-
entriesByRoute: this.#
|
|
95
|
+
entriesByRoute: this.#ssrEntriesByRoute,
|
|
78
96
|
clientManifest: this.#merged.clientManifest,
|
|
79
97
|
externalsByChunkUrl: this.#externalsByChunkUrl,
|
|
80
98
|
workspaceRoot: this.#app.workspace.workspaceRoot,
|
|
@@ -9,6 +9,8 @@ export const bundleDefine = (app: App, command: "build" | "start", renderEnv: "s
|
|
|
9
9
|
return {
|
|
10
10
|
"process.env.NODE_ENV": JSON.stringify(nodeEnv),
|
|
11
11
|
"process.env.AKAN_PUBLIC_RENDER_ENV": JSON.stringify(renderEnv),
|
|
12
|
+
//? NODE_ENV is production under `NODE_ENV=production akan start` too; this says the page's vendors were narrowed.
|
|
13
|
+
"process.env.AKAN_BUNDLE_COMMAND": JSON.stringify(command),
|
|
12
14
|
//? Sorted: Windows hands a child its environment sorted, while keys the config sets at runtime trail the parent's,
|
|
13
15
|
//? and the dev registries hash this object to tell a worker's build from the builder's.
|
|
14
16
|
...Object.fromEntries(
|
|
@@ -38,8 +38,12 @@ export const CLIENT_BUNDLE_NAMING = {
|
|
|
38
38
|
asset: "assets/[hash].[ext]",
|
|
39
39
|
} as const;
|
|
40
40
|
|
|
41
|
+
export interface DiscoverOptions {
|
|
42
|
+
importsAs?: ReadonlyMap<string, ReadonlyMap<string, string | null>>;
|
|
43
|
+
}
|
|
44
|
+
|
|
41
45
|
export interface ClientEntryDiscovery {
|
|
42
|
-
discover(seeds: string[]): Promise<string[]>;
|
|
46
|
+
discover(seeds: string[], options?: DiscoverOptions): Promise<string[]>;
|
|
43
47
|
invalidate?(files: string[]): void;
|
|
44
48
|
}
|
|
45
49
|
|
|
@@ -59,7 +63,6 @@ export interface BundleClientEntriesInternalOptions extends BundleClientEntriesO
|
|
|
59
63
|
externalSubpaths?: readonly string[];
|
|
60
64
|
externalAliases?: Partial<Record<string, string>>;
|
|
61
65
|
command?: "build" | "start";
|
|
62
|
-
/** Per absolute entry, the only names its alias re-exports, in place of every name the entry declares. */
|
|
63
66
|
entryExports?: Partial<Record<string, readonly string[]>>;
|
|
64
67
|
}
|
|
65
68
|
|
|
@@ -161,4 +161,32 @@ describe("GraphClientEntryDiscovery", () => {
|
|
|
161
161
|
expect(await discovery.discover([file("A.tsx")])).toEqual([file("C.tsx")]);
|
|
162
162
|
expect(await discovery.discover([file("B.tsx")])).toEqual([file("C.tsx")]);
|
|
163
163
|
});
|
|
164
|
+
|
|
165
|
+
test("a file walked with its imports swapped or dropped keeps that walk's answer out of every other walk", async () => {
|
|
166
|
+
const file = await tempDir();
|
|
167
|
+
await Bun.write(file("Layout.tsx"), 'import "./Both";\n');
|
|
168
|
+
await Bun.write(file("Page.tsx"), 'import "./Layout";\n');
|
|
169
|
+
await Bun.write(file("Both.tsx"), 'import "./Server";\nimport "./Browser";\n');
|
|
170
|
+
await Bun.write(file("Server.tsx"), 'import "./S";\n');
|
|
171
|
+
await Bun.write(file("Browser.tsx"), '"use client";\nexport const Browser = () => null;\n');
|
|
172
|
+
await Bun.write(file("S.tsx"), '"use client";\nexport const S = () => null;\n');
|
|
173
|
+
const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, (async () => null) as never);
|
|
174
|
+
const importsAs = new Map([[file("Layout.tsx"), new Map([["./Both", "./Server"]])]]);
|
|
175
|
+
|
|
176
|
+
expect(await discovery.discover([file("Page.tsx")])).toEqual([file("Browser.tsx"), file("S.tsx")]);
|
|
177
|
+
expect(await discovery.discover([file("Page.tsx")], { importsAs })).toEqual([file("S.tsx")]);
|
|
178
|
+
expect(await discovery.discover([file("Layout.tsx")], { importsAs })).toEqual([file("S.tsx")]);
|
|
179
|
+
expect(await discovery.discover([file("Page.tsx")])).toEqual([file("Browser.tsx"), file("S.tsx")]);
|
|
180
|
+
const dropped = new Map([[file("Both.tsx"), new Map([["./Browser", null]])]]);
|
|
181
|
+
expect(await discovery.discover([file("Page.tsx")], { importsAs: dropped })).toEqual([file("S.tsx")]);
|
|
182
|
+
|
|
183
|
+
const fresh = new GraphClientEntryDiscovery({ barrelImports: [] }, (async () => null) as never);
|
|
184
|
+
expect(await fresh.discover([file("Page.tsx")], { importsAs })).toEqual([file("S.tsx")]);
|
|
185
|
+
expect(await fresh.discover([file("Page.tsx")])).toEqual([file("Browser.tsx"), file("S.tsx")]);
|
|
186
|
+
expect(await fresh.discover([file("Layout.tsx")])).toEqual([file("Browser.tsx"), file("S.tsx")]);
|
|
187
|
+
|
|
188
|
+
await Bun.write(file("Server.tsx"), "export const server = 1;\n");
|
|
189
|
+
fresh.invalidate([file("Server.tsx")]);
|
|
190
|
+
expect(await fresh.discover([file("Page.tsx")], { importsAs })).toEqual([]);
|
|
191
|
+
});
|
|
164
192
|
});
|
|
@@ -4,7 +4,7 @@ import { SOURCE_EXTS } from "../akanApp/devHostPolicy";
|
|
|
4
4
|
import type { App } from "../commandDecorators";
|
|
5
5
|
import { BarrelAnalyzer, type BarrelExportMap } from "../transforms/barrelAnalyzer";
|
|
6
6
|
import { createTsconfigPackageResolver, rewriteBarrelImports } from "../transforms/barrelImportsPlugin";
|
|
7
|
-
import type { AkanConfig, ClientEntryDiscovery, ScannedImport } from "./clientBuildTypes";
|
|
7
|
+
import type { AkanConfig, ClientEntryDiscovery, DiscoverOptions, ScannedImport } from "./clientBuildTypes";
|
|
8
8
|
|
|
9
9
|
const USE_CLIENT_RE = /^\s*(?:\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\/\s*|\/\/[^\n]*\n\s*)*["']use client["']/;
|
|
10
10
|
const NODE_MODULES_RE = /[\\/]node_modules[\\/]/;
|
|
@@ -31,6 +31,8 @@ interface DiscoveryWalk {
|
|
|
31
31
|
//? Every file's result within this walk, partial ones included: the file the cycle went back to is an ancestor here
|
|
32
32
|
//? and brings the rest itself, and re-walking each path through a dense cycle grows exponentially.
|
|
33
33
|
memo: Map<string, Set<string>>;
|
|
34
|
+
importsAs?: DiscoverOptions["importsAs"];
|
|
35
|
+
reachable: Map<string, Set<string>>;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
const exportsKeyOf = (map: BarrelExportMap | null): string =>
|
|
@@ -50,6 +52,8 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
50
52
|
#resolvedFileCache = new Map<string, Lookup<string | null>>();
|
|
51
53
|
#resolvedSpecifierCache = new Map<string, Lookup<string | null>>();
|
|
52
54
|
#reachableEntriesCache = new Map<string, Set<string>>();
|
|
55
|
+
//? Keyed by a walk's `importsAs`: a file's answer under swapped imports holds only for walks swapping the same ones.
|
|
56
|
+
#swappedEntriesCaches = new WeakMap<object, Map<string, Set<string>>>();
|
|
53
57
|
#readFiles = new Set<string>();
|
|
54
58
|
#barrelImporters = new Map<string, Set<string>>();
|
|
55
59
|
//? Barrels a save went beside, by the analysis before it: a save of any component re-reads its barrel, and only an
|
|
@@ -70,17 +74,29 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
70
74
|
return new GraphClientEntryDiscovery(await app.getConfig(), await createTsconfigPackageResolver(app));
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
async discover(seeds: string[]): Promise<string[]> {
|
|
77
|
+
async discover(seeds: string[], { importsAs }: DiscoverOptions = {}): Promise<string[]> {
|
|
74
78
|
this.#settled = this.#settled.then(() => this.#dropRenarrowed());
|
|
75
79
|
await this.#settled;
|
|
76
80
|
const entries = new Set<string>();
|
|
81
|
+
const reachable = importsAs ? this.#swappedEntriesCache(importsAs) : this.#reachableEntriesCache;
|
|
77
82
|
for (const seed of seeds) {
|
|
78
|
-
const walk: DiscoveryWalk = { visiting: new Set(), partial: new Set(), memo: new Map() };
|
|
83
|
+
const walk: DiscoveryWalk = { visiting: new Set(), partial: new Set(), memo: new Map(), importsAs, reachable };
|
|
79
84
|
for (const entry of await this.#discoverFromFile(seed, walk)) entries.add(entry);
|
|
80
85
|
}
|
|
81
86
|
return Array.from(entries).sort();
|
|
82
87
|
}
|
|
83
88
|
|
|
89
|
+
async resolveImport(spec: string, importerDir: string): Promise<string | null> {
|
|
90
|
+
const resolved = await this.#resolveSpecifier(spec, importerDir);
|
|
91
|
+
return resolved ? path.resolve(resolved) : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#swappedEntriesCache(importsAs: object): Map<string, Set<string>> {
|
|
95
|
+
const cache = this.#swappedEntriesCaches.get(importsAs) ?? new Map<string, Set<string>>();
|
|
96
|
+
this.#swappedEntriesCaches.set(importsAs, cache);
|
|
97
|
+
return cache;
|
|
98
|
+
}
|
|
99
|
+
|
|
84
100
|
invalidate(files: string[]): void {
|
|
85
101
|
if (files.length === 0) return;
|
|
86
102
|
let moved = false;
|
|
@@ -95,6 +111,7 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
95
111
|
this.#epoch += 1;
|
|
96
112
|
// Reachable-entry results are transitive, so a changed child invalidates every ancestor.
|
|
97
113
|
this.#reachableEntriesCache.clear();
|
|
114
|
+
this.#swappedEntriesCaches = new WeakMap();
|
|
98
115
|
for (const [barrel, before] of this.#analyzer.forget(files))
|
|
99
116
|
if (!this.#reread.has(barrel)) this.#reread.set(barrel, before);
|
|
100
117
|
//? A file gone, or one never read, can leave a found resolution on its old target (`Chart.tsx` to
|
|
@@ -223,7 +240,8 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
223
240
|
async #discoverFromFile(file: string, walk: DiscoveryWalk): Promise<Set<string>> {
|
|
224
241
|
const epoch = this.#epoch;
|
|
225
242
|
const absPath = path.resolve(file);
|
|
226
|
-
const
|
|
243
|
+
const importsAs = walk.importsAs?.get(absPath);
|
|
244
|
+
const cached = walk.reachable.get(absPath);
|
|
227
245
|
if (cached) return new Set(cached);
|
|
228
246
|
const memo = walk.memo.get(absPath);
|
|
229
247
|
if (memo) {
|
|
@@ -253,7 +271,8 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
253
271
|
|
|
254
272
|
const importerDir = path.dirname(absPath);
|
|
255
273
|
for (const imp of facts.imports) {
|
|
256
|
-
const
|
|
274
|
+
const swapped = importsAs?.get(imp.path);
|
|
275
|
+
const spec = swapped === undefined ? imp.path : swapped;
|
|
257
276
|
if (!spec) continue;
|
|
258
277
|
if (NON_SOURCE_EXT_RE.test(spec)) continue;
|
|
259
278
|
const resolved = await this.#resolveSpecifier(spec, importerDir);
|
|
@@ -268,7 +287,7 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
268
287
|
#finishDiscovery(absPath: string, walk: DiscoveryWalk, entries: Set<string>, epoch: number): Set<string> {
|
|
269
288
|
walk.visiting.delete(absPath);
|
|
270
289
|
walk.memo.set(absPath, entries);
|
|
271
|
-
if (epoch === this.#epoch && !walk.partial.has(absPath))
|
|
290
|
+
if (epoch === this.#epoch && !walk.partial.has(absPath)) walk.reachable.set(absPath, entries);
|
|
272
291
|
return new Set(entries);
|
|
273
292
|
}
|
|
274
293
|
}
|