@akanjs/devkit 3.2.0-rc.3 → 3.2.0-rc.5
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 +62 -4
- package/akanApp/akanApp.host.test.ts +51 -0
- package/akanApp/akanApp.host.ts +3 -3
- package/akanContext.ts +2 -3
- package/applicationBuildRunner.test.ts +67 -0
- package/applicationBuildRunner.ts +1 -0
- 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/bundleDefine.ts +2 -0
- package/frontendBuild/clientBuildTypes.ts +0 -1
- 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/workflow/plainName.ts +16 -3
- package/constantFieldNames.ts +0 -18
|
@@ -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", () => {
|
|
@@ -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(
|
|
@@ -59,7 +59,6 @@ export interface BundleClientEntriesInternalOptions extends BundleClientEntriesO
|
|
|
59
59
|
externalSubpaths?: readonly string[];
|
|
60
60
|
externalAliases?: Partial<Record<string, string>>;
|
|
61
61
|
command?: "build" | "start";
|
|
62
|
-
/** Per absolute entry, the only names its alias re-exports, in place of every name the entry declares. */
|
|
63
62
|
entryExports?: Partial<Record<string, readonly string[]>>;
|
|
64
63
|
}
|
|
65
64
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, realpath, symlink } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { tempDirs, writeText as write } from "../testHelpers";
|
|
6
|
+
import { createBarrelImportsPlugin } from "../transforms/barrelImportsPlugin";
|
|
7
|
+
|
|
8
|
+
const makeTempRoot = tempDirs("akan-native-core-");
|
|
9
|
+
const akanjsRoot = path.resolve(import.meta.dir, "../../../akanjs");
|
|
10
|
+
const repoRoot = path.resolve(akanjsRoot, "../..");
|
|
11
|
+
const frameworkBarrels = ["akanjs/webkit", "akanjs/common", "akanjs/ui", "akanjs/server"];
|
|
12
|
+
const reactExternals = [
|
|
13
|
+
"react",
|
|
14
|
+
"react-dom",
|
|
15
|
+
"react-dom/client",
|
|
16
|
+
"react/jsx-runtime",
|
|
17
|
+
"react/jsx-dev-runtime",
|
|
18
|
+
"scheduler",
|
|
19
|
+
"react-server-dom-webpack/client.browser",
|
|
20
|
+
];
|
|
21
|
+
const pageRuntime = [
|
|
22
|
+
path.join(akanjsRoot, "server/rscClient.tsx"),
|
|
23
|
+
path.join(akanjsRoot, "server/rscSegmentOutlet.tsx"),
|
|
24
|
+
path.join(akanjsRoot, "ui/System/Client.tsx"),
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
interface PageBuild {
|
|
28
|
+
workspaceRoot: string;
|
|
29
|
+
paths: Record<string, string[]>;
|
|
30
|
+
barrelImports: string[];
|
|
31
|
+
entries: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const eagerInputsOf = async ({ workspaceRoot, paths, barrelImports, entries }: PageBuild) => {
|
|
35
|
+
const app = {
|
|
36
|
+
name: "demo",
|
|
37
|
+
cwdPath: path.join(workspaceRoot, "apps/demo"),
|
|
38
|
+
workspace: { workspaceRoot },
|
|
39
|
+
getConfig: async () => ({ barrelImports }),
|
|
40
|
+
getTsConfig: async () => ({ compilerOptions: { paths } }),
|
|
41
|
+
};
|
|
42
|
+
const result = await Bun.build({
|
|
43
|
+
entrypoints: entries,
|
|
44
|
+
target: "browser",
|
|
45
|
+
format: "esm",
|
|
46
|
+
splitting: true,
|
|
47
|
+
metafile: true,
|
|
48
|
+
throw: true,
|
|
49
|
+
conditions: ["production"],
|
|
50
|
+
external: reactExternals,
|
|
51
|
+
plugins: [await createBarrelImportsPlugin(app as never)],
|
|
52
|
+
});
|
|
53
|
+
const outputs = result.metafile?.outputs ?? {};
|
|
54
|
+
const eager = new Set<string>();
|
|
55
|
+
const load = (outputPath: string) => {
|
|
56
|
+
if (eager.has(outputPath) || !outputs[outputPath]) return;
|
|
57
|
+
eager.add(outputPath);
|
|
58
|
+
for (const imp of outputs[outputPath].imports) if (imp.kind === "import-statement") load(imp.path);
|
|
59
|
+
};
|
|
60
|
+
//? A dynamically imported module gets an entry chunk of its own too: only the page's entries start the walk.
|
|
61
|
+
for (const [outputPath, output] of Object.entries(outputs))
|
|
62
|
+
if (output.entryPoint && entries.includes(path.resolve(output.entryPoint))) load(outputPath);
|
|
63
|
+
return {
|
|
64
|
+
eager: [...eager].flatMap((outputPath) => Object.keys(outputs[outputPath]?.inputs ?? {})),
|
|
65
|
+
all: Object.keys(result.metafile?.inputs ?? {}),
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const eagerInputsOfAPage = async (store: string) => {
|
|
70
|
+
//? Real path: Bun names entry points through /private on macOS, where the temp dir is a symlink.
|
|
71
|
+
const root = await realpath(await makeTempRoot());
|
|
72
|
+
await mkdir(path.join(root, "node_modules"), { recursive: true });
|
|
73
|
+
await symlink(akanjsRoot, path.join(root, "node_modules/akanjs"));
|
|
74
|
+
const paths = { "@apps/*": ["./apps/*"] };
|
|
75
|
+
await write(path.join(root, "tsconfig.json"), JSON.stringify({ compilerOptions: { paths } }));
|
|
76
|
+
await write(path.join(root, "apps/demo/lib/st.ts"), store);
|
|
77
|
+
return await eagerInputsOf({
|
|
78
|
+
workspaceRoot: root,
|
|
79
|
+
paths,
|
|
80
|
+
barrelImports: frameworkBarrels,
|
|
81
|
+
entries: [...pageRuntime, path.join(akanjsRoot, "ui/Link/Anchor.tsx"), path.join(root, "apps/demo/lib/st.ts")],
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const isCore = (input: string) => /native\/packages\/core\//.test(input);
|
|
86
|
+
|
|
87
|
+
//? `akan sync` generates these, which a fresh checkout lacks; CI's sync job sets REQUIRE_SYNCED_LIBS after syncing.
|
|
88
|
+
const syncedLibFiles = [
|
|
89
|
+
...["shared", "util"].flatMap((lib) =>
|
|
90
|
+
[
|
|
91
|
+
"client.ts",
|
|
92
|
+
"server.ts",
|
|
93
|
+
"index.ts",
|
|
94
|
+
"ui/index.ts",
|
|
95
|
+
"webkit/index.ts",
|
|
96
|
+
"common/index.ts",
|
|
97
|
+
"lib/cnst.ts",
|
|
98
|
+
"lib/st.ts",
|
|
99
|
+
"lib/useClient.ts",
|
|
100
|
+
].map((file) => `libs/${lib}/${file}`),
|
|
101
|
+
),
|
|
102
|
+
...["user", "admin"].map((module) => `libs/shared/lib/${module}/index.ts`),
|
|
103
|
+
];
|
|
104
|
+
const libsSynced = syncedLibFiles.every((file) => existsSync(path.join(repoRoot, file)));
|
|
105
|
+
|
|
106
|
+
describe("@akanjs/native/core on a web page", () => {
|
|
107
|
+
test("the runtime, the system shell, Link and a store reading the host stay off it", async () => {
|
|
108
|
+
const { eager, all } = await eagerInputsOfAPage(
|
|
109
|
+
'import { isNativeShell, router } from "akanjs/client";\nimport { store } from "akanjs/store";\nexport const st = { isNativeShell, router, store };\n',
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(eager.filter(isCore)).toEqual([]);
|
|
113
|
+
expect(all.some(isCore)).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a store importing akanjs/client/native statically puts it on the page", async () => {
|
|
117
|
+
const { eager } = await eagerInputsOfAPage(
|
|
118
|
+
'import { isNativeShell } from "akanjs/client/native";\nexport const st = { isNativeShell };\n',
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(eager.some(isCore)).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test.skipIf(!libsSynced && !process.env.REQUIRE_SYNCED_LIBS)(
|
|
125
|
+
"a page drawing a shared Field and User template, on a store merging shared, stays off it",
|
|
126
|
+
async () => {
|
|
127
|
+
const { eager, all } = await eagerInputsOf({
|
|
128
|
+
workspaceRoot: repoRoot,
|
|
129
|
+
paths: (await Bun.file(path.join(repoRoot, "tsconfig.json")).json()).compilerOptions.paths,
|
|
130
|
+
barrelImports: [
|
|
131
|
+
...frameworkBarrels,
|
|
132
|
+
...["shared", "util"].flatMap((lib) =>
|
|
133
|
+
["ui", "webkit", "common", "client", "server"].map((facet) => `@libs/${lib}/${facet}`),
|
|
134
|
+
),
|
|
135
|
+
],
|
|
136
|
+
entries: [
|
|
137
|
+
...pageRuntime,
|
|
138
|
+
...["lib/useClient.ts", "lib/st.ts", "ui/Field.tsx", "lib/user/User.Template.tsx"].map((file) =>
|
|
139
|
+
path.join(repoRoot, "libs/shared", file),
|
|
140
|
+
),
|
|
141
|
+
],
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(eager.filter(isCore)).toEqual([]);
|
|
145
|
+
expect(all.some(isCore)).toBe(true);
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { SsrBaseArtifactBuilder } from "./ssrBaseArtifactBuilder";
|
|
3
|
+
|
|
4
|
+
describe("SsrBaseArtifactBuilder.outletModuleMap", () => {
|
|
5
|
+
const outlet = "pkgs/akanjs/server/rscSegmentOutlet.tsx";
|
|
6
|
+
const ssrOutput = "/dist/client-ssr/rscSegmentOutlet.js";
|
|
7
|
+
const manifest = {
|
|
8
|
+
[`${outlet}#AkanSegmentOutlet`]: { id: "/_akan/client/outlet.js", chunks: [], name: "AkanSegmentOutlet" },
|
|
9
|
+
[`${outlet}#AkanRouteHead`]: { id: "/_akan/client/outlet.js", chunks: [], name: "AkanRouteHead" },
|
|
10
|
+
"pkgs/akanjs/ui/Other.tsx#Other": { id: "/_akan/client/other.js", chunks: [], name: "Other" },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
test("keeps every export of the outlet module under its one client id, each served by the SSR build", () => {
|
|
14
|
+
expect(SsrBaseArtifactBuilder.outletModuleMap(manifest, outlet, ssrOutput)).toEqual({
|
|
15
|
+
"/_akan/client/outlet.js": {
|
|
16
|
+
AkanSegmentOutlet: { id: ssrOutput, chunks: [ssrOutput, ssrOutput], name: "AkanSegmentOutlet", async: true },
|
|
17
|
+
AkanRouteHead: { id: ssrOutput, chunks: [ssrOutput, ssrOutput], name: "AkanRouteHead", async: true },
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("is empty without the outlet's SSR build or client reference", () => {
|
|
23
|
+
expect(SsrBaseArtifactBuilder.outletModuleMap(manifest, outlet, undefined)).toEqual({});
|
|
24
|
+
expect(SsrBaseArtifactBuilder.outletModuleMap(manifest, undefined, ssrOutput)).toEqual({});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -153,25 +153,10 @@ export class SsrBaseArtifactBuilder {
|
|
|
153
153
|
const rscClientUrl = clientBundle.entryUrlsByAbsPath.get(rscClientEntry) ?? "";
|
|
154
154
|
const rscRuntimeSsrManifest = {
|
|
155
155
|
moduleLoading: null,
|
|
156
|
-
moduleMap:
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
!ssrOutput ||
|
|
162
|
-
key !== `${clientBundle.clientReferenceIdByAbsPath.get(rscSegmentOutletEntry)}#${row.name}`
|
|
163
|
-
) {
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
return [
|
|
167
|
-
row.id,
|
|
168
|
-
{ [row.name]: { id: ssrOutput, chunks: [ssrOutput, ssrOutput], name: row.name, async: true } },
|
|
169
|
-
];
|
|
170
|
-
})
|
|
171
|
-
.filter(
|
|
172
|
-
(entry): entry is [string, Record<string, { id: string; chunks: string[]; name: string; async: true }>] =>
|
|
173
|
-
Boolean(entry),
|
|
174
|
-
),
|
|
156
|
+
moduleMap: SsrBaseArtifactBuilder.outletModuleMap(
|
|
157
|
+
clientBundle.manifest,
|
|
158
|
+
clientBundle.clientReferenceIdByAbsPath.get(rscSegmentOutletEntry),
|
|
159
|
+
ssrBundle.entryOutputAbsByAbsPath.get(rscSegmentOutletEntry),
|
|
175
160
|
),
|
|
176
161
|
};
|
|
177
162
|
const vendorMap = Object.fromEntries(
|
|
@@ -190,6 +175,25 @@ export class SsrBaseArtifactBuilder {
|
|
|
190
175
|
},
|
|
191
176
|
};
|
|
192
177
|
}
|
|
178
|
+
|
|
179
|
+
/** Every export of the outlet module shares one client id, so each joins that id's row instead of replacing it. */
|
|
180
|
+
static outletModuleMap(
|
|
181
|
+
manifest: NonNullable<BaseBuildArtifact["rscRuntimeClientManifest"]>,
|
|
182
|
+
outletReferenceId: string | undefined,
|
|
183
|
+
ssrOutput: string | undefined,
|
|
184
|
+
) {
|
|
185
|
+
const moduleMap: Record<string, Record<string, { id: string; chunks: string[]; name: string; async: true }>> = {};
|
|
186
|
+
if (!ssrOutput || !outletReferenceId) return moduleMap;
|
|
187
|
+
for (const [key, row] of Object.entries(manifest)) {
|
|
188
|
+
if (key !== `${outletReferenceId}#${row.name}`) continue;
|
|
189
|
+
moduleMap[row.id] = {
|
|
190
|
+
...moduleMap[row.id],
|
|
191
|
+
[row.name]: { id: ssrOutput, chunks: [ssrOutput, ssrOutput], name: row.name, async: true },
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return moduleMap;
|
|
195
|
+
}
|
|
196
|
+
|
|
193
197
|
async #resolveAkanServerPath() {
|
|
194
198
|
const candidates: string[] = [];
|
|
195
199
|
try {
|
|
@@ -50,22 +50,43 @@ describe("VendorImportScanner", () => {
|
|
|
50
50
|
expect(used["akanjs/store"]).toEqual([]);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
test("reads the import at the end of a minified body too long for the call stack to walk", () => {
|
|
54
|
+
const body = Array.from({ length: 40_000 }, (_, i) => `a${i % 26}(${i})`).join(",");
|
|
55
|
+
|
|
56
|
+
const used = scan(`import{st}from"akanjs/store";function f(){${body},import("akanjs/fetch")}`);
|
|
57
|
+
|
|
58
|
+
expect(used).toEqual({ "akanjs/store": ["st"], "akanjs/common": [], "tailwind-merge": [] });
|
|
59
|
+
});
|
|
60
|
+
|
|
53
61
|
test("collects across every .js file of a directory", async () => {
|
|
54
62
|
const dir = await makeTempRoot();
|
|
55
63
|
await write(path.join(dir, "entry-a.js"), 'import{st}from"akanjs/store";');
|
|
56
64
|
await write(path.join(dir, "chunks/lazy.js"), 'import{RootStore}from"akanjs/store";');
|
|
57
65
|
await write(path.join(dir, "chunks/lazy.js.map"), 'import{ignored}from"akanjs/store";');
|
|
58
66
|
|
|
59
|
-
const used = await VendorImportScanner.exportsUsedIn(dir, ["akanjs/store"]);
|
|
67
|
+
const used = await VendorImportScanner.exportsUsedIn(dir, ["akanjs/store"], ["/_akan/client/entry-a.js"]);
|
|
60
68
|
|
|
61
69
|
expect(used).toEqual({ "akanjs/store": ["RootStore", "st"] });
|
|
62
70
|
});
|
|
63
71
|
|
|
64
|
-
test("reads a client output directory a build never wrote as importing nothing", async () => {
|
|
72
|
+
test("reads a client output directory a build never wrote as importing nothing when the routes have no client entry", async () => {
|
|
65
73
|
const dir = await makeTempRoot();
|
|
66
74
|
|
|
67
|
-
expect(await VendorImportScanner.exportsUsedIn(path.join(dir, "client"), ["akanjs/store"])).toEqual({
|
|
75
|
+
expect(await VendorImportScanner.exportsUsedIn(path.join(dir, "client"), ["akanjs/store"], [])).toEqual({
|
|
68
76
|
"akanjs/store": [],
|
|
69
77
|
});
|
|
70
78
|
});
|
|
79
|
+
|
|
80
|
+
test("fails, naming the entry, when a route client entry is not in the directory it scans", async () => {
|
|
81
|
+
const dir = await makeTempRoot();
|
|
82
|
+
await write(path.join(dir, "chunks/shared.js"), 'import{st}from"akanjs/store";');
|
|
83
|
+
const entries = ["/_akan/client/page-a1.js", "/_akan/client/page-a1.js", "/_akan/client/chunks/shared.js"];
|
|
84
|
+
|
|
85
|
+
await expect(VendorImportScanner.exportsUsedIn(dir, ["akanjs/store"], entries)).rejects.toThrow(
|
|
86
|
+
`no file under ${dir} for the route client entries /_akan/client/page-a1.js`,
|
|
87
|
+
);
|
|
88
|
+
await expect(
|
|
89
|
+
VendorImportScanner.exportsUsedIn(path.join(dir, "missing"), ["akanjs/store"], ["/_akan/client/page-a1.js"]),
|
|
90
|
+
).rejects.toThrow("/_akan/client/page-a1.js");
|
|
91
|
+
});
|
|
71
92
|
});
|
|
@@ -12,25 +12,40 @@ export class VendorImportScanner {
|
|
|
12
12
|
for (const specifier of specifiers) this.#names.set(specifier, new Set());
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
static async exportsUsedIn(
|
|
16
|
+
dir: string,
|
|
17
|
+
specifiers: readonly string[],
|
|
18
|
+
entryUrls: readonly string[],
|
|
19
|
+
): Promise<Record<string, string[]>> {
|
|
17
20
|
const scanner = new VendorImportScanner(specifiers);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
const scanned: string[] = [];
|
|
22
|
+
if (fs.existsSync(dir))
|
|
23
|
+
for await (const relative of new Bun.Glob("**/*.js").scan({ cwd: dir })) {
|
|
24
|
+
scanner.scan(await Bun.file(path.join(dir, relative)).text(), relative);
|
|
25
|
+
scanned.push(`/${relative.split(path.sep).join("/")}`);
|
|
26
|
+
}
|
|
27
|
+
const unread = [...new Set(entryUrls)].filter((url) => !scanned.some((relative) => url.endsWith(relative)));
|
|
28
|
+
if (unread.length > 0) {
|
|
29
|
+
const named = `${unread.slice(0, 3).join(", ")}${unread.length > 3 ? `, and ${unread.length - 3} more` : ""}`;
|
|
30
|
+
throw new Error(
|
|
31
|
+
`[VendorImportScanner] no file under ${dir} for the route client entries ${named}; narrowing the vendor entries without them would drop names those pages import`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
21
34
|
return scanner.usedExports();
|
|
22
35
|
}
|
|
23
36
|
|
|
24
37
|
scan(source: string, fileName = "chunk.js"): this {
|
|
25
38
|
if (![...this.#specifiers].some((specifier) => source.includes(specifier))) return this;
|
|
26
|
-
|
|
27
|
-
const
|
|
39
|
+
//? A stack, not recursion: minified code folds a long body into one comma chain, deeper than the call stack.
|
|
40
|
+
const stack: ts.Node[] = [ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.JS)];
|
|
41
|
+
for (let node = stack.pop(); node; node = stack.pop()) {
|
|
28
42
|
if (ts.isImportDeclaration(node)) this.#readImport(node);
|
|
29
43
|
else if (ts.isExportDeclaration(node)) this.#readExport(node);
|
|
30
44
|
else if (ts.isCallExpression(node)) this.#readCall(node);
|
|
31
|
-
ts.forEachChild(node,
|
|
32
|
-
|
|
33
|
-
|
|
45
|
+
ts.forEachChild(node, (child) => {
|
|
46
|
+
stack.push(child);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
34
49
|
return this;
|
|
35
50
|
}
|
|
36
51
|
|
|
@@ -123,18 +123,21 @@ describe("IncrementalBuilderHost", () => {
|
|
|
123
123
|
id: 1,
|
|
124
124
|
ok: false,
|
|
125
125
|
error: "builder exited to release bundler memory before answering; reload to retry",
|
|
126
|
+
kind: "unavailable",
|
|
126
127
|
},
|
|
127
128
|
{
|
|
128
129
|
type: "build-csr-res",
|
|
129
130
|
id: 2,
|
|
130
131
|
ok: false,
|
|
131
132
|
error: "builder exited to release bundler memory before answering; reload to retry",
|
|
133
|
+
kind: "unavailable",
|
|
132
134
|
},
|
|
133
135
|
{
|
|
134
136
|
type: "build-graph-res",
|
|
135
137
|
id: 4,
|
|
136
138
|
ok: false,
|
|
137
139
|
error: "builder exited to release bundler memory before answering; reload to retry",
|
|
140
|
+
kind: "unavailable",
|
|
138
141
|
},
|
|
139
142
|
]);
|
|
140
143
|
|
|
@@ -159,6 +162,7 @@ describe("IncrementalBuilderHost", () => {
|
|
|
159
162
|
id: 1,
|
|
160
163
|
ok: false,
|
|
161
164
|
error: "builder exited unexpectedly before answering; reload once it is back",
|
|
165
|
+
kind: "unavailable",
|
|
162
166
|
},
|
|
163
167
|
]);
|
|
164
168
|
|
|
@@ -168,7 +172,7 @@ describe("IncrementalBuilderHost", () => {
|
|
|
168
172
|
expect(host.send({ type: "build-route", id: 2, routeId: "b", seeds: [], knownEntries: [] })).toBe(true);
|
|
169
173
|
host.stop();
|
|
170
174
|
expect(messages).toEqual([
|
|
171
|
-
{ type: "build-route-res", id: 2, ok: false, error: "builder was stopped before answering" },
|
|
175
|
+
{ type: "build-route-res", id: 2, ok: false, error: "builder was stopped before answering", kind: "unavailable" },
|
|
172
176
|
]);
|
|
173
177
|
});
|
|
174
178
|
|
|
@@ -287,7 +287,8 @@ export class IncrementalBuilderHost {
|
|
|
287
287
|
const lost = [...this.#inFlight];
|
|
288
288
|
this.#inFlight.clear();
|
|
289
289
|
this.logger.warn(`failing ${lost.length} unanswered builder request(s): ${reason}`);
|
|
290
|
-
for (const [id, type] of lost)
|
|
290
|
+
for (const [id, type] of lost)
|
|
291
|
+
this.#onMessage({ type: `${type}-res`, id, ok: false, error: reason, kind: "unavailable" });
|
|
291
292
|
}
|
|
292
293
|
stop() {
|
|
293
294
|
this.#manualStop = true;
|
|
@@ -13,7 +13,7 @@ or {
|
|
|
13
13
|
$access <: or { `navigator.share`, `navigator.canShare` },
|
|
14
14
|
register_diagnostic(
|
|
15
15
|
span = $access,
|
|
16
|
-
message = "Android's WebView has no Web Share, so this does nothing in the app. Move it into a `webkit/` hook that branches on `isNativeApp()` from `akanjs/client
|
|
16
|
+
message = "Android's WebView has no Web Share, so this does nothing in the app. Move it into a `webkit/` hook that branches on `isNativeApp()` from `akanjs/client`; the runtime's `share` plugin is the app side.",
|
|
17
17
|
severity = "warn"
|
|
18
18
|
)
|
|
19
19
|
},
|
|
@@ -21,7 +21,7 @@ or {
|
|
|
21
21
|
$access <: or { `navigator.serviceWorker`, `Notification.requestPermission`, `Notification.permission` },
|
|
22
22
|
register_diagnostic(
|
|
23
23
|
span = $access,
|
|
24
|
-
message = "A native app page has no service worker or Notification API. Keep this in a `webkit/` hook behind `isNativeApp()`; native push is `push` from `akanjs/client/native`, and `usePushNotification` in libs/util wraps both.",
|
|
24
|
+
message = "A native app page has no service worker or Notification API. Keep this in a `webkit/` hook behind `isNativeApp()`; native push is `push` from `akanjs/client/native/push`, and `usePushNotification` in libs/util wraps both.",
|
|
25
25
|
severity = "warn"
|
|
26
26
|
)
|
|
27
27
|
},
|
|
@@ -37,7 +37,7 @@ or {
|
|
|
37
37
|
$access <: `navigator.vibrate`,
|
|
38
38
|
register_diagnostic(
|
|
39
39
|
span = $access,
|
|
40
|
-
message = "iOS has no `navigator.vibrate`. Use `haptics` from `akanjs/client/native` in a `webkit/` hook.",
|
|
40
|
+
message = "iOS has no `navigator.vibrate`. Use `haptics` from `akanjs/client/native/haptics` in a `webkit/` hook.",
|
|
41
41
|
severity = "warn"
|
|
42
42
|
)
|
|
43
43
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { constantFieldNames } from "./
|
|
2
|
+
import { constantFieldNames, unlabeledFieldNames } from "./moduleFieldNames";
|
|
3
3
|
|
|
4
|
-
describe("
|
|
4
|
+
describe("moduleFieldNames", () => {
|
|
5
5
|
test("names each field the code declares, chained or not", () => {
|
|
6
6
|
const content = `export class TaskInput extends via((field) => ({
|
|
7
7
|
title: field(String),
|
|
@@ -31,4 +31,26 @@ export const example = \`previousStatus: field(TaskStatus)\`;
|
|
|
31
31
|
|
|
32
32
|
expect(constantFieldNames(content)).toEqual(["title", "note"]);
|
|
33
33
|
});
|
|
34
|
+
|
|
35
|
+
test("unlabeledFieldNames finds a real label and reports one that is only commented out or inside a string", () => {
|
|
36
|
+
const constant = `export class TaskInput extends via((field) => ({
|
|
37
|
+
title: field(String),
|
|
38
|
+
content: field(String),
|
|
39
|
+
status: field(TaskStatus),
|
|
40
|
+
due: field(Date).optional(),
|
|
41
|
+
})) {}
|
|
42
|
+
`;
|
|
43
|
+
const dictionary = `export const dictionary = modelDictionary(["en", "ko"])
|
|
44
|
+
.model<Task>((t) => ({
|
|
45
|
+
title: t(["Title", "제목"]).desc(["Task title", "할 일 제목"]),
|
|
46
|
+
// content: t(["Content", "내용"]),
|
|
47
|
+
/* status: t(["Status", "상태"]), */
|
|
48
|
+
}))
|
|
49
|
+
.translate({
|
|
50
|
+
taskDueHint: ["due: pick a date", "마감: 날짜를 고르세요"],
|
|
51
|
+
});
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
expect(unlabeledFieldNames(constant, dictionary)).toEqual(["content", "status", "due"]);
|
|
55
|
+
});
|
|
34
56
|
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
const isFieldCall = (node: ts.Expression): boolean =>
|
|
4
|
+
ts.isCallExpression(node) &&
|
|
5
|
+
(ts.isIdentifier(node.expression)
|
|
6
|
+
? node.expression.text === "field"
|
|
7
|
+
: ts.isPropertyAccessExpression(node.expression) && isFieldCall(node.expression.expression));
|
|
8
|
+
|
|
9
|
+
const propertyNames = (content: string, declares: (initializer: ts.Expression) => boolean) => {
|
|
10
|
+
const names: string[] = [];
|
|
11
|
+
const visit = (node: ts.Node) => {
|
|
12
|
+
if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && declares(node.initializer))
|
|
13
|
+
names.push(node.name.text);
|
|
14
|
+
ts.forEachChild(node, visit);
|
|
15
|
+
};
|
|
16
|
+
visit(ts.createSourceFile("module.ts", content, ts.ScriptTarget.Latest));
|
|
17
|
+
return names;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const constantFieldNames = (content: string) => propertyNames(content, isFieldCall);
|
|
21
|
+
|
|
22
|
+
export const unlabeledFieldNames = (constantContent: string, dictionaryContent: string) => {
|
|
23
|
+
const labels = new Set(propertyNames(dictionaryContent, () => true));
|
|
24
|
+
return constantFieldNames(constantContent).filter((name) => !labels.has(name));
|
|
25
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.2.0-rc.
|
|
3
|
+
"version": "3.2.0-rc.5",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"./*": "./*.ts"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@akanjs/language": "3.2.0-rc.
|
|
44
|
+
"@akanjs/language": "3.2.0-rc.5",
|
|
45
45
|
"@earendil-works/pi-agent-core": "1.3.0",
|
|
46
46
|
"@earendil-works/pi-ai": "1.3.0",
|
|
47
47
|
"@earendil-works/pi-codemode": "1.3.0",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"@earendil-works/pi-tui": "1.3.0",
|
|
51
51
|
"@inquirer/prompts": "^8.7.3",
|
|
52
52
|
"@tailwindcss/node": "^4.3.3",
|
|
53
|
-
"akanjs": "3.2.0-rc.
|
|
53
|
+
"akanjs": "3.2.0-rc.5",
|
|
54
54
|
"chalk": "^6.0.1",
|
|
55
55
|
"commander": "^15.0.0",
|
|
56
56
|
"dayjs": "^1.11.23",
|