@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
|
@@ -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.6",
|
|
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.6",
|
|
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.6",
|
|
54
54
|
"chalk": "^6.0.1",
|
|
55
55
|
"commander": "^15.0.0",
|
|
56
56
|
"dayjs": "^1.11.23",
|
package/preview/PreviewView.ts
CHANGED
|
@@ -77,20 +77,12 @@ export class PreviewView {
|
|
|
77
77
|
}: PreviewProbeOptions = {},
|
|
78
78
|
): Promise<PreviewProbeResult> {
|
|
79
79
|
const logs: string[] = [];
|
|
80
|
-
const view = PreviewView.#
|
|
80
|
+
const view = await PreviewView.#start(
|
|
81
|
+
() => PreviewView.#open({ width, height, engine, onConsole: (line) => logs.push(line) }),
|
|
82
|
+
engine,
|
|
83
|
+
{ startTimeoutMs, timeoutMs },
|
|
84
|
+
);
|
|
81
85
|
try {
|
|
82
|
-
//? A browser that missed its start bound may still come up, so a later page retries it within only its own bound.
|
|
83
|
-
//? One that died or was closed instead is spawned afresh by the next view, which gets the full bound.
|
|
84
|
-
const startMs = PreviewView.#stalled.has(engine) ? Math.min(startTimeoutMs, timeoutMs) : startTimeoutMs;
|
|
85
|
-
const stalled = new Error(`Timed out starting the ${engine} browser after ${startMs / 1_000} s.`);
|
|
86
|
-
await PreviewView.#within(view.navigate("about:blank"), Date.now() + startMs, stalled).then(
|
|
87
|
-
() => PreviewView.#stalled.delete(engine),
|
|
88
|
-
(error: unknown) => {
|
|
89
|
-
if (error === stalled) PreviewView.#stalled.add(engine);
|
|
90
|
-
else PreviewView.#stalled.delete(engine);
|
|
91
|
-
throw error;
|
|
92
|
-
},
|
|
93
|
-
);
|
|
94
86
|
const deadline = Date.now() + timeoutMs;
|
|
95
87
|
const within = <T>(work: Promise<T>, what: string) =>
|
|
96
88
|
PreviewView.#within(
|
|
@@ -122,6 +114,33 @@ export class PreviewView {
|
|
|
122
114
|
}
|
|
123
115
|
}
|
|
124
116
|
|
|
117
|
+
static async #start(
|
|
118
|
+
open: () => WebViewLike,
|
|
119
|
+
engine: PreviewEngine,
|
|
120
|
+
{ startTimeoutMs, timeoutMs }: { startTimeoutMs: number; timeoutMs: number },
|
|
121
|
+
) {
|
|
122
|
+
for (let attempt = 1; ; attempt++) {
|
|
123
|
+
//? A browser that missed its start bound may still come up, so a later page retries it within only its own bound.
|
|
124
|
+
//? One that died or was closed instead is spawned afresh by the next view, which gets the full bound.
|
|
125
|
+
const startMs = PreviewView.#stalled.has(engine) ? Math.min(startTimeoutMs, timeoutMs) : startTimeoutMs;
|
|
126
|
+
const stalled = new Error(`Timed out starting the ${engine} browser after ${startMs / 1_000} s.`);
|
|
127
|
+
let view: WebViewLike | undefined;
|
|
128
|
+
try {
|
|
129
|
+
view = open();
|
|
130
|
+
await PreviewView.#within(view.navigate("about:blank"), Date.now() + startMs, stalled);
|
|
131
|
+
PreviewView.#stalled.delete(engine);
|
|
132
|
+
return view;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
await view?.close();
|
|
135
|
+
if (error === stalled) PreviewView.#stalled.add(engine);
|
|
136
|
+
else PreviewView.#stalled.delete(engine);
|
|
137
|
+
if (error === stalled || attempt > 1) throw error;
|
|
138
|
+
//? Bun reaps a browser that died only on a later timer turn; until then `new Bun.WebView()` throws "Failed to spawn Chrome".
|
|
139
|
+
await Bun.sleep(100);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
125
144
|
static async #load(
|
|
126
145
|
view: WebViewLike,
|
|
127
146
|
url: string,
|
|
@@ -178,9 +178,7 @@ Bun.WebView.closeAll();`,
|
|
|
178
178
|
console.log(await open());
|
|
179
179
|
const killed = open();
|
|
180
180
|
Bun.WebView.closeAll();
|
|
181
|
-
console.
|
|
182
|
-
await Bun.sleep(500);
|
|
183
|
-
console.log(await open());
|
|
181
|
+
console.log(await killed);
|
|
184
182
|
Bun.WebView.closeAll();`,
|
|
185
183
|
6,
|
|
186
184
|
);
|
|
@@ -194,4 +192,36 @@ Bun.WebView.closeAll();`,
|
|
|
194
192
|
},
|
|
195
193
|
browserTestMs,
|
|
196
194
|
);
|
|
195
|
+
|
|
196
|
+
test.skipIf(!slowChromeRuns)(
|
|
197
|
+
"starts a new browser for the page right after one that the browser died on",
|
|
198
|
+
async () => {
|
|
199
|
+
const { code, out, log } = await withSlowChrome(
|
|
200
|
+
`const { promise: loading, resolve } = Promise.withResolvers();
|
|
201
|
+
const server = Bun.serve({
|
|
202
|
+
port: 0,
|
|
203
|
+
fetch: (req) =>
|
|
204
|
+
new URL(req.url).pathname === "/hang"
|
|
205
|
+
? (resolve(), new Promise(() => undefined))
|
|
206
|
+
: new Response("<!doctype html><body>up</body>", { headers: { "content-type": "text/html" } }),
|
|
207
|
+
});
|
|
208
|
+
const open = (path) =>
|
|
209
|
+
PreviewView.probe(\`http://localhost:\${server.port}\${path}\`, { engine: "chrome" }).then(
|
|
210
|
+
(probe) => probe.text,
|
|
211
|
+
(error) => error.message,
|
|
212
|
+
);
|
|
213
|
+
const killed = open("/hang");
|
|
214
|
+
await loading;
|
|
215
|
+
Bun.WebView.closeAll();
|
|
216
|
+
console.error(await killed);
|
|
217
|
+
console.log(await open("/"));
|
|
218
|
+
server.stop(true);
|
|
219
|
+
Bun.WebView.closeAll();`,
|
|
220
|
+
0,
|
|
221
|
+
);
|
|
222
|
+
expect(out, log).toBe("up");
|
|
223
|
+
expect(code, log).toBe(0);
|
|
224
|
+
},
|
|
225
|
+
browserTestMs,
|
|
226
|
+
);
|
|
197
227
|
});
|